mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-01 23:19:00 -06:00
Enhancement: improve filter drop-down performance with virtual scrolling (#11973)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user