22 * The overlay list wrapper (SelectRenderable) and its selection delegations.
33 */
44import {
5+ BoxRenderable ,
6+ ScrollBoxRenderable ,
57 SelectRenderable ,
68 type KeyEvent ,
79 type RenderContext ,
@@ -133,20 +135,19 @@ function placeholderOptions(count: number): SelectOption[] {
133135}
134136
135137/**
136- * SelectRenderable keeps its scroll offset private. The painted window is the
137- * same centered slice Select computes from its public height and selection,
138- * so callers can read it without reflecting into private fields.
138+ * Select keeps scroll offset private. The window is the same centered slice
139+ * it would paint, taken from the reserved slot count rather than a reflective
140+ * read. The ScrollBox viewport is what actually scrolls; this only names the
141+ * slice.
139142 */
140- function selectWindow (
141- select : SelectRenderable ,
143+ function listWindow (
144+ index : number ,
142145 count : number ,
143- rowsPerItem : number ,
146+ slots : number ,
144147) : { offset : number ; visible : number } {
145148 if ( count === 0 ) return { offset : 0 , visible : 0 } ;
146- const linesPerItem = Math . max ( 1 , rowsPerItem ) ;
147- const visible = Math . max ( 1 , Math . floor ( select . height / linesPerItem ) ) ;
149+ const visible = Math . max ( 1 , slots ) ;
148150 const half = Math . floor ( visible / 2 ) ;
149- const index = select . getSelectedIndex ( ) ;
150151 const offset = Math . max (
151152 0 ,
152153 Math . min ( index - half , Math . max ( 0 , count - visible ) ) ,
@@ -166,12 +167,17 @@ export function createOverlayList(
166167 const activeIndex = opts . activeIndex ?? 0 ;
167168 let options = placeholderOptions ( count ) ;
168169
170+ const rowSpan = ( ) : number => Math . max ( 1 , shape . rowsPerItem ) ;
171+
169172 const build = ( ) : SelectRenderable =>
170173 new SelectRenderable ( ctx , {
171174 id : "shell-overlay-select" ,
172175 options,
173176 selectedIndex : activeIndex ,
174- height : shape . items * shape . rowsPerItem ,
177+ // Full list, not the visible window. The viewport ScrollBox clips it and
178+ // owns the scrollbar; a window-sized select would scroll a second time
179+ // inside itself and the bar would never see overflow.
180+ height : Math . max ( rowSpan ( ) , Math . max ( shape . items , count ) * rowSpan ( ) ) ,
175181 width : "100%" ,
176182 flexShrink : 0 ,
177183 showDescription : shape . rowsPerItem > 1 ,
@@ -193,6 +199,72 @@ export function createOverlayList(
193199
194200 let select = build ( ) ;
195201
202+ const viewport = new ScrollBoxRenderable ( ctx , {
203+ id : "shell-overlay-list-scroll" ,
204+ width : "100%" ,
205+ height : Math . max ( 1 , shape . items * rowSpan ( ) ) ,
206+ flexShrink : 0 ,
207+ // Arrow keys stay on the shell's selection handler. A focusable scrollbox
208+ // would page itself and steal the list.
209+ focusable : false ,
210+ viewportCulling : true ,
211+ scrollY : true ,
212+ stickyScroll : false ,
213+ rootOptions : { backgroundColor : UI . ground } ,
214+ contentOptions : { backgroundColor : UI . ground } ,
215+ viewportOptions : { backgroundColor : UI . ground } ,
216+ } ) ;
217+ viewport . horizontalScrollBar . visible = false ;
218+ viewport . horizontalScrollBar . focusable = false ;
219+ viewport . verticalScrollBar . focusable = false ;
220+ // Vertical bar stays on the native auto rule: visible once the select is
221+ // taller than the slot window. Forcing it off is the transcript's choice,
222+ // not the overlay host's.
223+ const placed = viewport . content . onSizeChange ;
224+ let revealing = false ;
225+
226+ // Absolute marker at the active row. scrollChildIntoView on the whole select
227+ // is a no-op when the select is taller than the viewport (both edges outside).
228+ const anchor = new BoxRenderable ( ctx , {
229+ id : "shell-overlay-active-anchor" ,
230+ position : "absolute" ,
231+ left : 0 ,
232+ width : 1 ,
233+ height : rowSpan ( ) ,
234+ top : 0 ,
235+ } ) ;
236+ viewport . add ( select ) ;
237+ viewport . add ( anchor ) ;
238+
239+ const reveal = ( ) : void => {
240+ if ( revealing ) return ;
241+ revealing = true ;
242+ try {
243+ const row = rowSpan ( ) ;
244+ const slots = Math . max ( 1 , shape . items ) ;
245+ viewport . height = slots * row ;
246+ select . height = Math . max ( row , Math . max ( slots , count ) * row ) ;
247+ const index = select . getSelectedIndex ( ) ;
248+ anchor . height = row ;
249+ anchor . top = index * row ;
250+ const { offset } = listWindow ( index , count , slots ) ;
251+ const y = count === 0 ? 0 : offset * row ;
252+ viewport . scrollTop = y ;
253+ if ( count > 0 && anchor . y !== 0 && viewport . viewport . y !== 0 ) {
254+ viewport . scrollChildIntoView ( anchor . id ) ;
255+ }
256+ // Applied last: scrollTop clamps until the bar has a content size, and
257+ // that clamp rewrites translateY. The viewport paints this translation.
258+ viewport . content . translateY = - y ;
259+ } finally {
260+ revealing = false ;
261+ }
262+ } ;
263+ viewport . content . onSizeChange = ( ) => {
264+ placed ?. call ( viewport . content ) ;
265+ reveal ( ) ;
266+ } ;
267+
196268 const reshape = ( next : Partial < OverlayListShape > ) : void => {
197269 const merged = { ...shape , ...next } ;
198270 if (
@@ -205,16 +277,22 @@ export function createOverlayList(
205277 // (clamped to the new count) so a resize does not snap the cursor back.
206278 const current = select . getSelectedIndex ( ) ;
207279 shape = merged ;
280+ if ( select . parent ) viewport . remove ( select ) ;
208281 select = build ( ) ;
282+ viewport . add ( select , 0 ) ;
209283 select . setSelectedIndex (
210284 count === 0 ? 0 : Math . min ( count - 1 , Math . max ( 0 , current ) ) ,
211285 ) ;
286+ reveal ( ) ;
212287 } ;
213288
289+ reveal ( ) ;
290+
214291 return {
215292 get select ( ) {
216293 return select ;
217294 } ,
295+ viewport,
218296 get activeIndex ( ) {
219297 return select . getSelectedIndex ( ) ;
220298 } ,
@@ -225,14 +303,15 @@ export function createOverlayList(
225303 return shape . rowsPerItem ;
226304 } ,
227305 get offset ( ) {
228- return selectWindow ( select , count , shape . rowsPerItem ) . offset ;
306+ return listWindow ( select . getSelectedIndex ( ) , count , shape . items ) . offset ;
229307 } ,
230308 get count ( ) {
231309 return count ;
232310 } ,
233311 move ( delta : number ) {
234312 if ( delta < 0 ) select . moveUp ( - delta ) ;
235313 else if ( delta > 0 ) select . moveDown ( delta ) ;
314+ reveal ( ) ;
236315 } ,
237316 page ( dir : - 1 | 1 ) {
238317 this . move ( dir * ( shape . items > 1 ? shape . items - 1 : 1 ) ) ;
@@ -242,20 +321,29 @@ export function createOverlayList(
242321 select . setSelectedIndex (
243322 Math . max ( 0 , Math . min ( count - 1 , Math . floor ( index ) ) ) ,
244323 ) ;
324+ reveal ( ) ;
245325 } ,
246326 setCount ( next : number ) {
247327 count = Math . max ( 0 , Math . floor ( next ) ) ;
248328 options = placeholderOptions ( count ) ;
249329 select . options = options ;
330+ reveal ( ) ;
250331 } ,
251332 setHeight ( items : number , rowsPerItem ?: number ) {
252333 reshape ( {
253334 items : Math . max ( 0 , Math . floor ( items ) ) ,
254335 ...( rowsPerItem ? { rowsPerItem } : { } ) ,
255336 } ) ;
256337 } ,
338+ scrollActiveIntoView ( ) {
339+ reveal ( ) ;
340+ } ,
257341 visibleRange ( ) {
258- const { offset, visible } = selectWindow ( select , count , shape . rowsPerItem ) ;
342+ const { offset, visible } = listWindow (
343+ select . getSelectedIndex ( ) ,
344+ count ,
345+ shape . items ,
346+ ) ;
259347 return { start : offset , end : Math . min ( count , offset + visible ) } ;
260348 } ,
261349 } ;
0 commit comments