diff --git a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.spec.ts b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.spec.ts index 8691431e8bcc..c35644fa0524 100644 --- a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.spec.ts +++ b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.spec.ts @@ -1,12 +1,17 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { Observable, of } from 'rxjs'; +import { fakeAsync, tick } from '@angular/core/testing'; + import { ConfirmationService } from 'primeng/api'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotHttpErrorManagerService, DotMessageService, DotOsgiService } from '@dotcms/data-access'; -import { DotPluginsExtraPackagesComponent } from './dot-plugins-extra-packages.component'; +import { + DotPluginsExtraPackagesComponent, + SEARCH_DEBOUNCE_MS +} from './dot-plugins-extra-packages.component'; describe('DotPluginsExtraPackagesComponent', () => { let spectator: Spectator; @@ -153,6 +158,100 @@ describe('DotPluginsExtraPackagesComponent', () => { }); }); + describe('search', () => { + const PACKAGES_TEXT = 'com.example.foo\ncom.example.bar\norg.example.baz\ncom.example.qux'; + + function searchInput(): HTMLInputElement | null { + const host = spectator.query(byTestId('plugins-extra-packages-search')); + if (!host) return null; + return host instanceof HTMLInputElement + ? host + : host.querySelector('input'); + } + + beforeEach(() => { + component.extraPackages.set(PACKAGES_TEXT); + spectator.detectChanges(); + }); + + it('should keep focus on the search input after typing (debounced)', fakeAsync(() => { + const input = searchInput()!; + const textarea = nativeTextarea()!; + expect(input).toBeTruthy(); + expect(textarea).toBeTruthy(); + + input.focus(); + expect(document.activeElement).toBe(input); + + spectator.typeInElement('com', input); + tick(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); + + expect(component.matchCount()).toBe(3); + expect(document.activeElement).toBe(input); + })); + + it('should pre-select the first match on the textarea while typing without focusing it', fakeAsync(() => { + const input = searchInput()!; + const textarea = nativeTextarea()!; + + input.focus(); + spectator.typeInElement('com', input); + tick(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); + + expect(document.activeElement).toBe(input); + const firstStart = component.matchPositions()[0]; + expect(textarea.selectionStart).toBe(firstStart); + expect(textarea.selectionEnd).toBe(firstStart + 'com'.length); + })); + + it('should move focus to the textarea on next-match navigation', fakeAsync(() => { + const input = searchInput()!; + const textarea = nativeTextarea()!; + + input.focus(); + spectator.typeInElement('com', input); + tick(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); + + component.nextMatch(); + + expect(document.activeElement).toBe(textarea); + expect(component.currentMatchIndex()).toBe(1); + const secondStart = component.matchPositions()[1]; + expect(textarea.selectionStart).toBe(secondStart); + expect(textarea.selectionEnd).toBe(secondStart + 'com'.length); + })); + + it('should move focus to the textarea on prev-match navigation', fakeAsync(() => { + const input = searchInput()!; + const textarea = nativeTextarea()!; + + input.focus(); + spectator.typeInElement('com', input); + tick(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); + + component.prevMatch(); + + expect(document.activeElement).toBe(textarea); + expect(component.currentMatchIndex()).toBe(component.matchCount() - 1); + })); + + it('should leave focus untouched when the query has no matches', fakeAsync(() => { + const input = searchInput()!; + + input.focus(); + spectator.typeInElement('does-not-exist', input); + tick(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); + + expect(component.matchCount()).toBe(0); + expect(document.activeElement).toBe(input); + })); + }); + describe('save', () => { it('should save the current packages and close the dialog with true', () => { component.extraPackages.set('pkg1\npkg2\npkg3'); diff --git a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.ts b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.ts index 3f0646106780..f0698f5b3fba 100644 --- a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.ts +++ b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-extra-packages/dot-plugins-extra-packages.component.ts @@ -35,7 +35,7 @@ import { DotMessagePipe } from '@dotcms/ui'; export const EXTRA_PACKAGES_RESET_RESULT = 'restart' as const; /** Debounce delay (ms) before scrolling to a match while the user is typing. */ -const SEARCH_DEBOUNCE_MS = 500; +export const SEARCH_DEBOUNCE_MS = 500; @Component({ selector: 'dot-plugins-extra-packages', @@ -115,13 +115,13 @@ export class DotPluginsExtraPackagesComponent { nextMatch(): void { const next = (this.currentMatchIndex() + 1) % this.matchCount(); this.currentMatchIndex.set(next); - this.#scrollToMatch(next); + this.#scrollToMatch(next, { focus: true }); } prevMatch(): void { const prev = (this.currentMatchIndex() - 1 + this.matchCount()) % this.matchCount(); this.currentMatchIndex.set(prev); - this.#scrollToMatch(prev); + this.#scrollToMatch(prev, { focus: true }); } save(): void { @@ -180,8 +180,12 @@ export class DotPluginsExtraPackagesComponent { }); } - /** Focuses the textarea, selects the match at `index`, and scrolls to center it. */ - #scrollToMatch(index: number): void { + /** + * Selects the match at `index` and scrolls to center it. Focus is only moved to the + * textarea when `focus: true` is passed (explicit ▲/▼ navigation); the search-as-you-type + * path must leave focus on the search input so further keystrokes don't edit the textarea. + */ + #scrollToMatch(index: number, { focus = false }: { focus?: boolean } = {}): void { const positions = this.matchPositions(); const textarea = this.textareaRef()?.nativeElement; if (!textarea || positions.length === 0) return; @@ -189,7 +193,7 @@ export class DotPluginsExtraPackagesComponent { const start = positions[index]; const end = start + this.searchQuery().length; - textarea.focus(); + if (focus) textarea.focus(); textarea.setSelectionRange(start, end); textarea.scrollTop = this.#scrollTopForChar(textarea, start); }