Skip to content

Commit d03da28

Browse files
release: 0.0.9 updated typeahead-added virtual scrolling
1 parent 78abf94 commit d03da28

File tree

3 files changed

+177
-10
lines changed

3 files changed

+177
-10
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@angular-bootstrap/ngbootstrap",
3-
"version": "0.0.8",
3+
"version": "0.0.9",
44
"description": "Angular UI library providing datagrid, drag-and-drop, pagination, stepper, splitter, typeahead and chips components with Bootstrap-friendly styling.",
55
"author": {
66
"name": "Harmeet Singh"

src/typeahead/src/typeahead/typeahead.component.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,23 @@ import { NgbTypeaheadItem } from './typeahead.types';
99
standalone: true,
1010
imports: [NgbTypeaheadComponent],
1111
template: `
12+
<div>
1213
<ngb-typeahead
1314
[data]="items"
1415
[multiSelect]="multi"
1516
[chips]="chips"
1617
[updateOnTab]="updateOnTab"
1718
[limit]="limit"
19+
[vScroll]="vScroll"
20+
[vItemSize]="vItemSize"
1821
[debounceTime]="0"
1922
[characterTyped]="characterTyped"
2023
(selectedItems)="selected = $event"
2124
(selectionChange)="selectionChanges = selectionChanges + 1"
2225
(onScrollEvent)="scrolled = true"
2326
></ngb-typeahead>
27+
<button type="button" class="btn btn-sm btn-secondary ms-2" id="after-typeahead">After</button>
28+
</div>
2429
`,
2530
})
2631
class HostComponent {
@@ -31,6 +36,8 @@ class HostComponent {
3136
{ id: 3, label: 'UAE', value: 'uae' },
3237
];
3338
limit = 10;
39+
vScroll = false;
40+
vItemSize = 40;
3441
multi = false;
3542
chips = false;
3643
updateOnTab = true;
@@ -218,6 +225,28 @@ describe('NgbTypeaheadComponent', () => {
218225
expect(duration).toBeLessThan(150);
219226
});
220227

228+
it('virtualizes rendered options when vScroll is enabled', () => {
229+
host.items = Array.from({ length: 10000 }, (_, i) => ({
230+
id: i,
231+
label: `Item ${i}`,
232+
value: i,
233+
}));
234+
host.limit = 0;
235+
host.vScroll = true;
236+
host.vItemSize = 40;
237+
fixture.detectChanges();
238+
239+
const input = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
240+
input.value = 'Item';
241+
input.dispatchEvent(new Event('input'));
242+
fixture.detectChanges();
243+
244+
expect(host.typeahead.filtered.length).toBe(10000);
245+
const buttons = fixture.debugElement.queryAll(By.css('button.dropdown-item'));
246+
expect(buttons.length).toBeGreaterThan(0);
247+
expect(buttons.length).toBeLessThan(30);
248+
});
249+
221250
it('keeps focus on input when updateOnTab adds a chip', () => {
222251
host.multi = true;
223252
host.chips = true;
@@ -238,6 +267,71 @@ describe('NgbTypeaheadComponent', () => {
238267
expect(document.activeElement).toBe(input);
239268
});
240269

270+
it('supports keyboard navigation and selection keys', () => {
271+
const input = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
272+
input.focus();
273+
input.value = 'a';
274+
input.dispatchEvent(new Event('input'));
275+
fixture.detectChanges();
276+
277+
expect(host.typeahead.overlayVisible).toBe(true);
278+
expect(host.typeahead.activeIndex).toBe(0);
279+
280+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
281+
fixture.detectChanges();
282+
expect(host.typeahead.activeIndex).toBe(1);
283+
284+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
285+
fixture.detectChanges();
286+
expect(host.typeahead.activeIndex).toBe(2);
287+
288+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
289+
fixture.detectChanges();
290+
expect(host.typeahead.activeIndex).toBe(0);
291+
292+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
293+
fixture.detectChanges();
294+
expect(host.typeahead.activeIndex).toBeGreaterThanOrEqual(0);
295+
296+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
297+
fixture.detectChanges();
298+
expect(host.selected.length).toBe(1);
299+
expect(host.typeahead.overlayVisible).toBe(false);
300+
});
301+
302+
it('selects the highlighted item and closes on Tab without preventing default', () => {
303+
const input = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
304+
input.focus();
305+
input.value = 'a';
306+
input.dispatchEvent(new Event('input'));
307+
fixture.detectChanges();
308+
309+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
310+
fixture.detectChanges();
311+
312+
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' });
313+
input.dispatchEvent(tabEvent);
314+
fixture.detectChanges();
315+
316+
expect(tabEvent.defaultPrevented).toBe(false);
317+
expect(host.selected.length).toBe(1);
318+
expect(host.typeahead.overlayVisible).toBe(false);
319+
});
320+
321+
it('hides the popup on Escape', () => {
322+
const input = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
323+
input.focus();
324+
input.value = 'a';
325+
input.dispatchEvent(new Event('input'));
326+
fixture.detectChanges();
327+
328+
expect(host.typeahead.overlayVisible).toBe(true);
329+
330+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
331+
fixture.detectChanges();
332+
expect(host.typeahead.overlayVisible).toBe(false);
333+
});
334+
241335
it('supports formControlName (single select)', fakeAsync(() => {
242336
const reactiveFixture = TestBed.createComponent(ReactiveHostSingleComponent);
243337
reactiveFixture.detectChanges();

src/typeahead/src/typeahead/typeahead.component.ts

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)