@@ -97,7 +97,6 @@ import { NgbChipsComponent } from '../../../chips/src/chips/chips.component';
9797 align-items: center;
9898 gap: 0.5rem;
9999 }
100-
101100 .typeahead-item-checkbox {
102101 display: inline-flex;
103102 align-items: center;
@@ -189,7 +188,7 @@ import { NgbChipsComponent } from '../../../chips/src/chips/chips.component';
189188 role="listbox"
190189 (scroll)="onScroll($event)"
191190 >
192- <div [style.height.px]="beforePadding"></div>
191+ <div *ngIf="vScroll" [style.height.px]="beforePadding"></div>
193192
194193 <button
195194 *ngFor="let item of visible; trackBy: trackById; let idx = index"
@@ -231,7 +230,7 @@ import { NgbChipsComponent } from '../../../chips/src/chips/chips.component';
231230 </div>
232231 </button>
233232
234- <div [style.height.px]="afterPadding"></div>
233+ <div *ngIf="vScroll" [style.height.px]="afterPadding"></div>
235234
236235 <div *ngIf="showNoResults" class="dropdown-item text-muted text-center">
237236 {{ i18n?.noResults || 'No results' }}
@@ -261,6 +260,8 @@ export class NgbTypeaheadComponent implements AfterViewInit, OnChanges, OnDestro
261260 @Input ( ) updateOnTab = true ;
262261 @Input ( ) separator : string | string [ ] = ',' ;
263262 @Input ( ) chips = false ;
263+ @Input ( ) vScroll = false ;
264+ @Input ( ) vItemSize = 40 ;
264265 // `TemplateRef` types can become non-assignable in monorepo setups with multiple Angular installations.
265266 // Using `any` keeps the API flexible while still supporting Angular templates at runtime.
266267 @Input ( ) itemTemplate ?: any ;
@@ -310,6 +311,7 @@ export class NgbTypeaheadComponent implements AfterViewInit, OnChanges, OnDestro
310311 itemHeight = 40 ;
311312 beforePadding = 0 ;
312313 afterPadding = 0 ;
314+ private viewportStartIndex = 0 ;
313315 private debounceId ?: ReturnType < typeof setTimeout > ;
314316
315317 private onControlChange : ( value : any ) => void = ( ) => { } ;
@@ -322,6 +324,11 @@ export class NgbTypeaheadComponent implements AfterViewInit, OnChanges, OnDestro
322324 }
323325
324326 ngOnChanges ( changes : SimpleChanges ) : void {
327+ if ( changes [ 'vScroll' ] || changes [ 'vItemSize' ] ) {
328+ const nextSize = Number ( this . vItemSize ) ;
329+ this . itemHeight = Number . isFinite ( nextSize ) && nextSize > 0 ? nextSize : 40 ;
330+ this . updateViewport ( this . scroller ?. nativeElement . scrollTop || 0 ) ;
331+ }
325332 if ( changes [ 'data' ] && ! changes [ 'data' ] . firstChange ) {
326333 this . applyFilter ( this . query ) ;
327334 }
@@ -532,13 +539,34 @@ export class NgbTypeaheadComponent implements AfterViewInit, OnChanges, OnDestro
532539 this . focusInput ( ) ;
533540 return ;
534541 }
542+ if ( event . key === 'Tab' && this . overlayVisible ) {
543+ const active = this . visible [ this . activeIndex ] ;
544+ if ( active ) {
545+ this . selectItem ( active ) ;
546+ this . hideOverlay ( event ) ;
547+ }
548+ return ;
549+ }
535550 if ( event . key === 'ArrowDown' || event . key === 'ArrowUp' ) {
536551 event . preventDefault ( ) ;
537552 this . moveActive ( event . key === 'ArrowDown' ? 1 : - 1 ) ;
553+ } else if ( event . key === 'Home' ) {
554+ if ( this . overlayVisible ) {
555+ event . preventDefault ( ) ;
556+ this . setActiveGlobalIndex ( 0 ) ;
557+ }
558+ } else if ( event . key === 'End' ) {
559+ if ( this . overlayVisible ) {
560+ event . preventDefault ( ) ;
561+ this . setActiveGlobalIndex ( this . filtered . length - 1 ) ;
562+ }
538563 } else if ( event . key === 'Enter' ) {
539564 event . preventDefault ( ) ;
540565 const active = this . visible [ this . activeIndex ] || this . visible [ 0 ] ;
541- if ( active ) this . selectItem ( active ) ;
566+ if ( active ) {
567+ this . selectItem ( active ) ;
568+ this . hideOverlay ( event ) ;
569+ }
542570 } else if ( event . key === 'Escape' ) {
543571 if ( this . overlayVisible ) {
544572 event . preventDefault ( ) ;
@@ -735,13 +763,22 @@ export class NgbTypeaheadComponent implements AfterViewInit, OnChanges, OnDestro
735763 }
736764
737765 private updateViewport ( scrollTop : number ) {
766+ if ( ! this . vScroll ) {
767+ this . beforePadding = 0 ;
768+ this . afterPadding = 0 ;
769+ this . visible = this . filtered ;
770+ this . viewportStartIndex = 0 ;
771+ this . activeIndex = this . visible . length ? Math . min ( this . activeIndex , this . visible . length - 1 ) : - 1 ;
772+ return ;
773+ }
738774 const total = this . filtered . length ;
739775 const visibleCount = Math . ceil ( this . viewportHeight / this . itemHeight ) + 2 ;
740776 const start = Math . max ( Math . floor ( scrollTop / this . itemHeight ) , 0 ) ;
741777 const end = Math . min ( start + visibleCount , total ) ;
742778 this . beforePadding = start * this . itemHeight ;
743779 this . afterPadding = Math . max ( total - end , 0 ) * this . itemHeight ;
744780 this . visible = this . filtered . slice ( start , end ) ;
781+ this . viewportStartIndex = start ;
745782 this . activeIndex = this . visible . length ? Math . min ( this . activeIndex , this . visible . length - 1 ) : - 1 ;
746783 }
747784
@@ -777,13 +814,49 @@ export class NgbTypeaheadComponent implements AfterViewInit, OnChanges, OnDestro
777814 if ( ! this . overlayVisible ) {
778815 this . showOverlay ( ) ;
779816 }
780- if ( ! this . visible . length ) return ;
817+ if ( ! this . filtered . length ) return ;
818+ const currentGlobal = this . activeGlobalIndex ( ) ;
819+ const total = this . filtered . length ;
820+ const nextGlobal = currentGlobal < 0 ? 0 : ( currentGlobal + direction + total ) % total ;
821+ this . setActiveGlobalIndex ( nextGlobal ) ;
822+ }
781823
782- const nextIndex = this . activeIndex < 0 ? 0 : ( this . activeIndex + direction + this . visible . length ) % this . visible . length ;
783- this . activeIndex = nextIndex ;
824+ private activeGlobalIndex ( ) : number {
825+ if ( this . activeIndex < 0 ) return - 1 ;
826+ return this . viewportStartIndex + this . activeIndex ;
827+ }
784828
785- const buttons = this . scroller ?. nativeElement . querySelectorAll < HTMLButtonElement > ( 'button.dropdown-item' ) ;
786- buttons ?. [ this . activeIndex ] ?. focus ( ) ;
829+ private setActiveGlobalIndex ( globalIndex : number ) {
830+ if ( ! this . filtered . length ) {
831+ this . activeIndex = - 1 ;
832+ return ;
833+ }
834+ const clamped = Math . max ( 0 , Math . min ( globalIndex , this . filtered . length - 1 ) ) ;
835+ const scroller = this . scroller ?. nativeElement ;
836+ if ( ! scroller ) {
837+ this . activeIndex = 0 ;
838+ return ;
839+ }
840+
841+ const itemTop = clamped * this . itemHeight ;
842+ const itemBottom = itemTop + this . itemHeight ;
843+ const viewTop = scroller . scrollTop ;
844+ const viewBottom = viewTop + ( scroller . clientHeight || this . viewportHeight ) ;
845+
846+ let nextScrollTop = viewTop ;
847+ if ( itemTop < viewTop ) {
848+ nextScrollTop = itemTop ;
849+ } else if ( itemBottom > viewBottom ) {
850+ nextScrollTop = Math . max ( 0 , itemBottom - ( scroller . clientHeight || this . viewportHeight ) ) ;
851+ }
852+
853+ if ( nextScrollTop !== viewTop ) {
854+ scroller . scrollTop = nextScrollTop ;
855+ }
856+
857+ this . updateViewport ( scroller . scrollTop ) ;
858+ this . activeIndex = Math . max ( 0 , Math . min ( clamped - this . viewportStartIndex , this . visible . length - 1 ) ) ;
859+ this . cdr . markForCheck ( ) ;
787860 }
788861
789862 private validateItem ( item : NgbTypeaheadItem ) {
0 commit comments