diff --git a/src-ui/e2e/document-list/document-list.spec.ts b/src-ui/e2e/document-list/document-list.spec.ts
index 0a7b54fcb..9aa4d2fdc 100644
--- a/src-ui/e2e/document-list/document-list.spec.ts
+++ b/src-ui/e2e/document-list/document-list.spec.ts
@@ -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()
diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
index 58e2beebb..31138c314 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
+++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
@@ -34,8 +34,8 @@
@if (selectionModel.items) {
-
- @for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
+
+
@if (allowSelectNone || item.id) {
}
- }
-
+
+
}
@if (editing) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
index 2ecf95f2b..9dc5f019f 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
+++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
@@ -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)
diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
index ec5425630..f5b6ba89c 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
+++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
@@ -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 {
+ 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) {
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
index 72d3f948c..ad75e5d39 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
@@ -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]