11import type { ListSelect } from "#elements/ak-list-select/ak-list-select" ;
2- import { AnchorPositionSupported , AnchorSizeSupported } from "#elements/dialogs/positioning" ;
2+ import { findScrollableAncestor , placeAnchoredPopover } from "#elements/dialogs/positioning" ;
33
44import type { ReactiveController , ReactiveControllerHost } from "lit" ;
55
66const DEFAULT_REFOCUS_DELAY = 250 ;
77
8- /**
9- * Firefox reports support for anchor positioning but mis-renders anchored elements inside
10- * dialogs, so use the shared capability checks rather than CSS.supports directly.
11- */
12- const CSSAnchorPositioningSupported = AnchorPositionSupported && AnchorSizeSupported ;
13-
148interface SearchSelectMenuHost extends ReactiveControllerHost , HTMLElement {
159 open : boolean ;
1610 readOnly : boolean ;
@@ -33,11 +27,6 @@ export class SearchSelectMenuController implements ReactiveController {
3327 host . addController ( this ) ;
3428 }
3529
36- public hostConnected ( ) {
37- // Styling hook: opt this instance into the CSS anchor-positioning block.
38- this . host . toggleAttribute ( "data-anchor-css" , CSSAnchorPositioningSupported ) ;
39- }
40-
4130 /**
4231 * Reconcile the popover's actual open state with the host's `open` state.
4332 * Called after the host updates so the menu has rendered.
@@ -54,9 +43,9 @@ export class SearchSelectMenuController implements ReactiveController {
5443
5544 if ( this . host . open && ! this . host . readOnly && ! popoverOpen ) {
5645 menu . showPopover ( ) ;
57- // Start tracking synchronously (not via the async `toggle` event) so the
58- // fallback places the menu in the same frame it becomes visible — no flash
59- // at the UA default position.
46+ // Start tracking synchronously (not via the async `toggle` event) so the menu
47+ // is placed in the same frame it becomes visible — no flash at the UA default
48+ // position.
6049 this . #startTracking( ) ;
6150 } else if ( ( ! this . host . open || this . host . readOnly ) && popoverOpen ) {
6251 menu . hidePopover ( ) ;
@@ -77,7 +66,9 @@ export class SearchSelectMenuController implements ReactiveController {
7766 const dismissedByThisClick = event . timeStamp - this . #lastLightDismiss < refocusDelay ;
7867
7968 this . host . open = dismissedByThisClick ? false : ! this . host . open ;
80- this . getInput ( ) ?. focus ( ) ;
69+ // preventScroll: an auto-scroll here would shift the anchor in the same beat the
70+ // menu is being placed against it.
71+ this . getInput ( ) ?. focus ( { preventScroll : true } ) ;
8172 } ;
8273
8374 public readonly handleMenuToggle = ( event : ToggleEvent ) => {
@@ -114,7 +105,7 @@ export class SearchSelectMenuController implements ReactiveController {
114105
115106 if ( menuCanScroll ) return ;
116107
117- const scroller = this . # findScrollableAncestor( ) ;
108+ const scroller = findScrollableAncestor ( this . host ) ;
118109 if ( ! scroller ) return ;
119110
120111 const deltaY = ( ( ) => {
@@ -134,42 +125,13 @@ export class SearchSelectMenuController implements ReactiveController {
134125 event . preventDefault ( ) ;
135126 } ;
136127
137- /**
138- * Walk the flattened (composed) tree upward from the host — crossing shadow
139- * boundaries and slots — to the nearest vertically scrollable ancestor.
140- */
141- #findScrollableAncestor( ) : HTMLElement | null {
142- const composedParent = ( node : Node ) : Node | null => {
143- const slot = ( node as Element ) . assignedSlot ;
144- if ( slot ) return slot ;
145-
146- const parent = node . parentNode ;
147- return parent instanceof ShadowRoot ? parent . host : parent ;
148- } ;
149-
150- for ( let node = composedParent ( this . host ) ; node ; node = composedParent ( node ) ) {
151- if ( ! ( node instanceof HTMLElement ) ) continue ;
152-
153- const { overflowY } = getComputedStyle ( node ) ;
154- const scrollable =
155- overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay" ;
156-
157- if ( scrollable && node . scrollHeight > node . clientHeight ) {
158- return node ;
159- }
160- }
161-
162- return null ;
163- }
164-
165128 #startTracking( ) {
166129 const input = this . getInput ( ) ;
167130 const menu = this . getMenu ( ) ;
168131 if ( ! input || ! menu ) return ;
169132
170133 // Close the menu when its anchor input is no longer visible — scrolled out
171- // of the viewport, clipped away by a scroll container, or hidden. This works
172- // in every browser regardless of anchor-positioning support.
134+ // of the viewport, clipped away by a scroll container, or hidden.
173135 this . #anchorObserver?. disconnect ( ) ;
174136 this . #anchorObserver = new IntersectionObserver (
175137 ( entries ) => {
@@ -181,21 +143,17 @@ export class SearchSelectMenuController implements ReactiveController {
181143 ) ;
182144 this . #anchorObserver. observe ( input ) ;
183145
184- // Native CSS anchor positioning handles placement and tracks scrolling on its
185- // own — nothing else to do.
186- if ( CSSAnchorPositioningSupported ) return ;
187-
188- // Otherwise position the menu imperatively and keep it in sync. We can't rely
189- // on a global scroll listener: `scroll` events are `composed: false`, so
190- // scrolling inside a shadow-rendered container (e.g. a modal dialog body)
191- // never reaches `window`. Instead we re-place the menu each animation frame
192- // while open, which also covers nested scrollers, layout shifts, and resizes.
146+ // Position the menu imperatively and keep it in sync. We can't rely on a global
147+ // scroll listener: `scroll` events are `composed: false`, so scrolling inside a
148+ // shadow-rendered container (e.g. a modal dialog body) never reaches `window`.
149+ // Instead we re-place the menu each animation frame while open, which also covers
150+ // nested scrollers, layout shifts, resizes, and options arriving late.
193151 let lastGeometry = "" ;
194152 const reflow = ( ) => {
195153 const rect = this . getInput ( ) ?. getBoundingClientRect ( ) ;
196154
197155 if ( rect ) {
198- const geometry = `${ rect . left } ,${ rect . top } ,${ rect . bottom } ,${ rect . width } ,${ window . innerHeight } ` ;
156+ const geometry = `${ rect . left } ,${ rect . top } ,${ rect . bottom } ,${ rect . width } ,${ window . innerHeight } , ${ menu . scrollHeight } ` ;
199157
200158 if ( geometry !== lastGeometry ) {
201159 lastGeometry = geometry ;
@@ -220,35 +178,11 @@ export class SearchSelectMenuController implements ReactiveController {
220178 }
221179 }
222180
223- /**
224- * Position the menu against the input imperatively, matching the CSS
225- * anchor-positioning behavior (below by default, flip above when there's no
226- * room, width matched to the input, capped height). Only used where CSS anchor
227- * positioning is unavailable.
228- */
229181 #positionMenu( ) {
230182 const input = this . getInput ( ) ;
231183 const menu = this . getMenu ( ) ;
232184 if ( ! input || ! menu ) return ;
233185
234- const rect = input . getBoundingClientRect ( ) ;
235- const viewportHeight = window . innerHeight ;
236- const maxHeight = Math . round ( viewportHeight * 0.4 ) ;
237- const menuHeight = Math . min ( menu . offsetHeight || maxHeight , maxHeight ) ;
238- const spaceBelow = viewportHeight - rect . bottom ;
239- const flipUp = spaceBelow < menuHeight && rect . top > spaceBelow ;
240-
241- menu . style . position = "fixed" ;
242- menu . style . left = `${ Math . round ( rect . left ) } px` ;
243- menu . style . width = `${ Math . round ( rect . width ) } px` ;
244- menu . style . maxHeight = `${ maxHeight } px` ;
245-
246- if ( flipUp ) {
247- menu . style . top = "auto" ;
248- menu . style . bottom = `${ Math . round ( viewportHeight - rect . top ) } px` ;
249- } else {
250- menu . style . bottom = "auto" ;
251- menu . style . top = `${ Math . round ( rect . bottom ) } px` ;
252- }
186+ placeAnchoredPopover ( input , menu , { matchAnchorWidth : true } ) ;
253187 }
254188}
0 commit comments