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]