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.getByRole('button', { name: 'Tags' }).click()
await page
.getByRole('textbox', { name: 'Filter tags' })
.fill('TagWithPartial')
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
await page.getByRole('button', { name: 'Apply' }).click()

View File

@@ -34,8 +34,8 @@
</div>
</div>
@if (selectionModel.items) {
<div class="items" #buttonItems>
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
@if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button
[item]="item"
@@ -45,12 +45,11 @@
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
(click)="setButtonItemIndex(i - 1)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
}
</div>
</div>
</cdk-virtual-scroll-viewport>
}
@if (editing) {
@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 { provideHttpClientTesting } from '@angular/common/http/testing'
import {
@@ -64,7 +65,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
imports: [NgxBootstrapIconsModule.pick(allIcons)],
imports: [NgxBootstrapIconsModule.pick(allIcons), ScrollingModule],
}).compileComponents()
hotkeyService = TestBed.inject(HotKeyService)
@@ -265,18 +266,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement
)
expect(
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(2)
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
component.filterText = 'Tag2'
fixture.detectChanges()
expect(
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(1)
expect(component.buttonsViewport.getRenderedRange().end).toEqual(1) // filtered
component.dropdown.close()
expect(component.filterText).toHaveLength(0)
}))
@@ -331,6 +325,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -376,6 +372,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -412,6 +410,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)

View File

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

View File

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