Enhancement: improve filter drop-down performance with virtual scrolling (#11973)

This commit is contained in:
shamoon
2026-02-01 14:13:39 -08:00
committed by GitHub
parent a42df003fb
commit 6442fdc235
5 changed files with 73 additions and 32 deletions

View File

@@ -180,6 +180,9 @@ test('bulk edit', async ({ page }) => {
await page.locator('pngx-document-card-small').nth(2).click() await page.locator('pngx-document-card-small').nth(2).click()
await page.getByRole('button', { name: 'Tags' }).click() await page.getByRole('button', { name: 'Tags' }).click()
await page
.getByRole('textbox', { name: 'Filter tags' })
.fill('TagWithPartial')
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click() await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
await page.getByRole('button', { name: 'Apply' }).click() await page.getByRole('button', { name: 'Apply' }).click()

View File

@@ -34,8 +34,8 @@
</div> </div>
</div> </div>
@if (selectionModel.items) { @if (selectionModel.items) {
<div class="items" #buttonItems> <cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) { <div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
@if (allowSelectNone || item.id) { @if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button <pngx-toggleable-dropdown-button
[item]="item" [item]="item"
@@ -45,12 +45,11 @@
[count]="getUpdatedDocumentCount(item.id)" [count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)" (toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)" (exclude)="excludeClicked(item.id)"
(click)="setButtonItemIndex(i - 1)"
[disabled]="disabled"> [disabled]="disabled">
</pngx-toggleable-dropdown-button> </pngx-toggleable-dropdown-button>
} }
} </div>
</div> </cdk-virtual-scroll-viewport>
} }
@if (editing) { @if (editing) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) { @if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {

View File

@@ -1,3 +1,4 @@
import { ScrollingModule } from '@angular/cdk/scrolling'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { import {
@@ -64,7 +65,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
], ],
imports: [NgxBootstrapIconsModule.pick(allIcons)], imports: [NgxBootstrapIconsModule.pick(allIcons), ScrollingModule],
}).compileComponents() }).compileComponents()
hotkeyService = TestBed.inject(HotKeyService) hotkeyService = TestBed.inject(HotKeyService)
@@ -265,18 +266,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual( expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
) )
expect( expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(2)
component.filterText = 'Tag2' component.filterText = 'Tag2'
fixture.detectChanges() fixture.detectChanges()
expect( expect(component.buttonsViewport.getRenderedRange().end).toEqual(1) // filtered
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(1)
component.dropdown.close() component.dropdown.close()
expect(component.filterText).toHaveLength(0) expect(component.filterText).toHaveLength(0)
})) }))
@@ -331,6 +325,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges() fixture.detectChanges()
tick(100) tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement = const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl) expect(document.activeElement).toEqual(filterInputEl)
@@ -376,6 +372,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges() fixture.detectChanges()
tick(100) tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement = const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl) expect(document.activeElement).toEqual(filterInputEl)
@@ -412,6 +410,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges() fixture.detectChanges()
tick(100) tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement = const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl) expect(document.activeElement).toEqual(filterInputEl)

View File

@@ -1,3 +1,7 @@
import {
CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling'
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { import {
Component, Component,
@@ -627,18 +631,27 @@ export class FilterableDropdownSelectionModel {
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDropdownModule, NgbDropdownModule,
NgClass, NgClass,
ScrollingModule,
], ],
}) })
export class FilterableDropdownComponent export class FilterableDropdownComponent
extends LoadingComponentWithPermissions extends LoadingComponentWithPermissions
implements OnInit implements OnInit
{ {
public readonly FILTERABLE_BUTTON_HEIGHT_PX = 42
private filterPipe = inject(FilterPipe) private filterPipe = inject(FilterPipe)
private hotkeyService = inject(HotKeyService) private hotkeyService = inject(HotKeyService)
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChild('dropdown') dropdown: NgbDropdown @ViewChild('dropdown') dropdown: NgbDropdown
@ViewChild('buttonItems') buttonItems: ElementRef @ViewChild('buttonsViewport') buttonsViewport: CdkVirtualScrollViewport
private get renderedButtons(): Array<HTMLButtonElement> {
return Array.from(
this.buttonsViewport.elementRef.nativeElement.querySelectorAll('button')
)
}
public popperOptions = pngxPopperOptions public popperOptions = pngxPopperOptions
@@ -752,6 +765,14 @@ export class FilterableDropdownComponent
private keyboardIndex: number private keyboardIndex: number
public get scrollViewportHeight(): number {
const filteredLength = this.filterPipe.transform(
this.items,
this.filterText
).length
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
}
constructor() { constructor() {
super() super()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
@@ -776,6 +797,10 @@ export class FilterableDropdownComponent
} }
} }
public trackByItem(index: number, item: MatchingModel) {
return item?.id ?? index
}
applyClicked() { applyClicked() {
if (this.selectionModel.isDirty()) { if (this.selectionModel.isDirty()) {
this.dropdown.close() this.dropdown.close()
@@ -794,6 +819,7 @@ export class FilterableDropdownComponent
if (open) { if (open) {
setTimeout(() => { setTimeout(() => {
this.listFilterTextInput?.nativeElement.focus() this.listFilterTextInput?.nativeElement.focus()
this.buttonsViewport?.checkViewportSize()
}, 0) }, 0)
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
@@ -861,12 +887,14 @@ export class FilterableDropdownComponent
event.preventDefault() event.preventDefault()
} }
} else if (event.target instanceof HTMLButtonElement) { } else if (event.target instanceof HTMLButtonElement) {
this.syncKeyboardIndexFromButton(event.target)
this.focusNextButtonItem() this.focusNextButtonItem()
event.preventDefault() event.preventDefault()
} }
break break
case 'ArrowUp': case 'ArrowUp':
if (event.target instanceof HTMLButtonElement) { if (event.target instanceof HTMLButtonElement) {
this.syncKeyboardIndexFromButton(event.target)
if (this.keyboardIndex === 0) { if (this.keyboardIndex === 0) {
this.listFilterTextInput.nativeElement.focus() this.listFilterTextInput.nativeElement.focus()
} else { } else {
@@ -903,15 +931,18 @@ export class FilterableDropdownComponent
if (setFocus) this.setButtonItemFocus() if (setFocus) this.setButtonItemFocus()
} }
setButtonItemFocus() { private syncKeyboardIndexFromButton(button: HTMLButtonElement) {
this.buttonItems.nativeElement.children[ // because of virtual scrolling, re-calculate the index
this.keyboardIndex const idx = this.renderedButtons.indexOf(button)
]?.children[0].focus() if (idx >= 0) {
this.keyboardIndex = this.buttonsViewport.getRenderedRange().start + idx
}
} }
setButtonItemIndex(index: number) { setButtonItemFocus() {
// just track the index in case user uses arrows const offset =
this.keyboardIndex = index this.keyboardIndex - this.buttonsViewport.getRenderedRange().start
this.renderedButtons[offset]?.focus()
} }
hideCount(item: ObjectWithPermissions) { hideCount(item: ObjectWithPermissions) {

View File

@@ -1306,7 +1306,8 @@ describe('FilterEditorComponent', () => {
const tagsFilterableDropdown = fixture.debugElement.queryAll( const tagsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[0] )[0]
tagsFilterableDropdown.triggerEventHandler('opened') tagsFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const tagButton = tagsFilterableDropdown.queryAll( const tagButton = tagsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
)[0] )[0]
@@ -1324,7 +1325,8 @@ describe('FilterEditorComponent', () => {
const tagsFilterableDropdown = fixture.debugElement.queryAll( const tagsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[0] // Tags dropdown )[0] // Tags dropdown
tagsFilterableDropdown.triggerEventHandler('opened') tagsFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const tagButtons = tagsFilterableDropdown.queryAll( const tagButtons = tagsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
) )
@@ -1375,7 +1377,8 @@ describe('FilterEditorComponent', () => {
const correspondentsFilterableDropdown = fixture.debugElement.queryAll( const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[1] // Corresp dropdown )[1] // Corresp dropdown
correspondentsFilterableDropdown.triggerEventHandler('opened') correspondentsFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const correspondentButtons = correspondentsFilterableDropdown.queryAll( const correspondentButtons = correspondentsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
) )
@@ -1414,7 +1417,8 @@ describe('FilterEditorComponent', () => {
const correspondentsFilterableDropdown = fixture.debugElement.queryAll( const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[1] )[1]
correspondentsFilterableDropdown.triggerEventHandler('opened') correspondentsFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const notAssignedButton = correspondentsFilterableDropdown.queryAll( const notAssignedButton = correspondentsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
)[0] )[0]
@@ -1445,7 +1449,8 @@ describe('FilterEditorComponent', () => {
const documentTypesFilterableDropdown = fixture.debugElement.queryAll( const documentTypesFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[2] // DocType dropdown )[2] // DocType dropdown
documentTypesFilterableDropdown.triggerEventHandler('opened') documentTypesFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const documentTypeButtons = documentTypesFilterableDropdown.queryAll( const documentTypeButtons = documentTypesFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
) )
@@ -1484,7 +1489,8 @@ describe('FilterEditorComponent', () => {
const docTypesFilterableDropdown = fixture.debugElement.queryAll( const docTypesFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[2] )[2]
docTypesFilterableDropdown.triggerEventHandler('opened') docTypesFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const notAssignedButton = docTypesFilterableDropdown.queryAll( const notAssignedButton = docTypesFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
)[0] )[0]
@@ -1515,7 +1521,8 @@ describe('FilterEditorComponent', () => {
const storagePathFilterableDropdown = fixture.debugElement.queryAll( const storagePathFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[3] // StoragePath dropdown )[3] // StoragePath dropdown
storagePathFilterableDropdown.triggerEventHandler('opened') storagePathFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const storagePathButtons = storagePathFilterableDropdown.queryAll( const storagePathButtons = storagePathFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
) )
@@ -1554,7 +1561,8 @@ describe('FilterEditorComponent', () => {
const storagePathsFilterableDropdown = fixture.debugElement.queryAll( const storagePathsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(FilterableDropdownComponent)
)[3] )[3]
storagePathsFilterableDropdown.triggerEventHandler('opened') storagePathsFilterableDropdown.componentInstance.dropdownOpenChange(true)
fixture.detectChanges()
const notAssignedButton = storagePathsFilterableDropdown.queryAll( const notAssignedButton = storagePathsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent) By.directive(ToggleableDropdownButtonComponent)
)[0] )[0]