feat(search): enforce minimum character limit for search input and display error message

This commit is contained in:
2026-07-24 14:17:41 -03:00
parent 9bb4ff61f7
commit 4e06145f44
6 changed files with 102 additions and 25 deletions

View File

@@ -21,25 +21,37 @@
<div <div
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end" class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
> >
<div class="input-group store-layout__search" role="search"> <div class="store-layout__search-wrapper">
<label class="visually-hidden" for="store-search-input">Buscar productos</label> <div class="input-group store-layout__search" role="search">
<input <label class="visually-hidden" for="store-search-input">Buscar productos</label>
id="store-search-input" <input
type="search" id="store-search-input"
class="form-control store-layout__search-input border-end-0 rounded-start" type="search"
placeholder="Buscar productos" class="form-control store-layout__search-input border-end-0 rounded-start"
autocomplete="off" placeholder="Buscar productos"
[formControl]="searchControl" autocomplete="off"
(keydown.enter)="submitSearch($event)" [attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
/> [attr.aria-invalid]="showSearchError()"
<button [minlength]="minSearchLength"
type="button" [formControl]="searchControl"
class="btn store-layout__search-button border-start-0 rounded-end px-3" (input)="clearSearchError()"
aria-label="Buscar" (keydown.enter)="submitSearch($event)"
(click)="submitSearch()" />
> <button
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> type="button"
</button> class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div> </div>
@if (ticketsMenu(); as menu) { @if (ticketsMenu(); as menu) {

View File

@@ -41,11 +41,22 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite; animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
} }
.store-layout__search { .store-layout__search-wrapper {
width: min(100%, 260px); width: min(100%, 260px);
min-width: 0; min-width: 0;
} }
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input { .store-layout__search-input {
color: #666666; color: #666666;
border-color: #cccccc; border-color: #cccccc;

View File

@@ -37,6 +37,8 @@ export class StoreHeaderComponent {
readonly searchSubmit = output<string>(); readonly searchSubmit = output<string>();
protected readonly isUserDropdownOpen = signal(false); protected readonly isUserDropdownOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true }); protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
@@ -80,8 +82,16 @@ export class StoreHeaderComponent {
const term = this.searchControl.value.trim(); const term = this.searchControl.value.trim();
if (term.length >= 2) { if (term.length < this.minSearchLength) {
this.searchSubmit.emit(term); this.showSearchError.set(true);
return;
} }
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
} }
} }

View File

@@ -257,6 +257,29 @@ describe('StoreLayoutComponent', () => {
}); });
}); });
it('requires at least three characters to search', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
(header.componentInstance as any).searchControl.setValue('ab');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
});
it('renders the primary tickets action when the tenant has the tickets menu', () => { it('renders the primary tickets action when the tenant has the tickets menu', () => {
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');

View File

@@ -105,4 +105,20 @@ describe('SearchPageComponent', () => {
'Se muestran 1 de 10 resultados.', 'Se muestran 1 de 10 resultados.',
); );
}); });
it('does not search when the query has fewer than three characters', () => {
TestBed.overrideProvider(ActivatedRoute, {
useValue: {
queryParamMap: of(convertToParamMap({ q: 'ab', page: '1' })),
},
});
const fixture = TestBed.createComponent(SearchPageComponent);
fixture.detectChanges();
expect(searchCatalog).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
});
}); });

View File

@@ -41,6 +41,7 @@ interface SearchRouteState {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class SearchPageComponent { export class SearchPageComponent {
private readonly minSearchLength = 3;
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly catalogService = inject(CatalogService); private readonly catalogService = inject(CatalogService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -94,11 +95,15 @@ export class SearchPageComponent {
tap(({ query }) => { tap(({ query }) => {
this.query.set(query); this.query.set(query);
this.results.set(null); this.results.set(null);
this.error.set(query.length >= 2 ? null : 'Ingresá al menos 2 caracteres para buscar.'); this.error.set(
this.loading.set(query.length >= 2); query.length >= this.minSearchLength
? null
: `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`,
);
this.loading.set(query.length >= this.minSearchLength);
}), }),
switchMap(({ query, page }) => { switchMap(({ query, page }) => {
if (query.length < 2) { if (query.length < this.minSearchLength) {
return of(null); return of(null);
} }