fix(search): app-skeleton/app-empty-state for live /search results, icon-only close buttons

/search routes to CatalogContainerComponent + CatalogSearchResultsComponent
(app.routes.ts:45-47) - confirmed the live search surface (pages/search/* is
unrouted dead code per KNOWN-ISSUES.md item 13, not touched here).

- search-results.component: hand-rolled `.skeleton-card` shimmer (hardcoded
  hex gradient colors, duplicate keyframes) replaced with the shared
  app-skeleton primitive the dead pages/search copy already used, but the
  live component never got. Bare `<div class="empty-state"><h3>/<p></div>`
  replaced with app-empty-state + app-icon, matching CatalogEmptyStateComponent's
  established pattern elsewhere in the same feature.
- Added distinct empty-state messaging: a too-short query (<3 chars, mirrors
  the existing minSearchLength/isQueryTooShort convention from the dead
  pages/search/search.component.ts) now shows "Enter at least N characters"
  instead of being indistinguishable from a genuine no-results-for-X state,
  which now shows the query and a retry hint (search.noResults/noResultsFor/
  noResultsHint/minLength i18n keys already existed, just unused on this path).
- catalog-container.component.html: icon-only close/remove buttons (filter
  drawer, sort sheet, grid sheet, saved-search chip) rendered a literal "x"
  text character with no app-icon - now use app-icon name="x".

Debounce (220ms, search.facade.ts), URL query-param sync, keyboard
arrow-key suggestion navigation (role=combobox/aria-activedescendant), and
filter/sort discoverability were all verified already correct on this path,
no changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-25 20:16:09 +04:00
parent 6d663eb238
commit 3a0b1a3746
5 changed files with 57 additions and 34 deletions

View File

@@ -5,9 +5,13 @@
</div>
@if (loading) {
<div class="results-skeletons">
<div class="results-skeletons" aria-busy="true">
@for (_ of [1,2,3,4,5,6,7,8]; track $index) {
<div class="skeleton-card"></div>
<div class="skeleton-card">
<app-skeleton shape="rect" height="220px" />
<app-skeleton shape="text" width="72%" height="16px" />
<app-skeleton shape="text" width="40%" height="14px" />
</div>
}
</div>
} @else if (products.length > 0) {
@@ -28,9 +32,26 @@
(compareToggled)="compareToggled.emit($event)"
(shareRequested)="shareRequested.emit($event)" />
} @else {
<div class="empty-state card">
<h3>{{ 'catalog.emptyResultsTitle' | translate }}</h3>
<p>{{ 'catalog.emptyResultsDescription' | translate }}</p>
<div class="results-empty-state card">
@if (queryTooShort) {
<app-empty-state
[title]="'search.minLength' | translate:{ count: minSearchLength }"
[description]="null">
<span slot="icon"><app-icon name="search" [size]="40" /></span>
</app-empty-state>
} @else if (searchQuery) {
<app-empty-state
[title]="'search.noResults' | translate"
[description]="('search.noResultsFor' | translate:{ query: searchQuery }) + ' ' + ('search.noResultsHint' | translate)">
<span slot="icon"><app-icon name="search" [size]="40" /></span>
</app-empty-state>
} @else {
<app-empty-state
[title]="'catalog.emptyResultsTitle' | translate"
[description]="'catalog.emptyResultsDescription' | translate">
<span slot="icon"><app-icon name="search" [size]="40" /></span>
</app-empty-state>
}
</div>
}

View File

@@ -28,31 +28,18 @@
.skeleton-card {
min-height: 340px;
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 13px);
background:
linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%) 16px 16px / calc(100% - 32px) calc(100% - 32px) no-repeat,
var(--bg-secondary);
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
background: var(--bg-secondary);
}
.empty-state {
.results-empty-state {
min-height: 180px;
display: grid;
place-content: center;
gap: 6px;
text-align: center;
padding: 18px;
}
.empty-state h3,
.empty-state p {
margin: 0;
}
.empty-state p {
color: var(--text-secondary);
}
.results-pager {
@@ -109,7 +96,3 @@
text-align: center;
}
}
@keyframes shimmer {
to { background-position: -200% 0; }
}

View File

@@ -3,11 +3,14 @@ import { Product } from '../../../../../core/products/models/product-domain.mode
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
import { CatalogProductGridComponent } from '../product-grid/product-grid.component';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { EmptyStateComponent } from '../../../../../shared/ui/empty-state/empty-state.component';
import { IconComponent } from '../../../../../shared/ui/icon/icon.component';
import { SkeletonComponent } from '../../../../../shared/ui/skeleton/skeleton.component';
@Component({
selector: 'app-catalog-search-results',
standalone: true,
imports: [CatalogProductGridComponent, TranslatePipe],
imports: [CatalogProductGridComponent, TranslatePipe, EmptyStateComponent, IconComponent, SkeletonComponent],
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -28,6 +31,9 @@ export class CatalogSearchResultsComponent {
@Input() comparedIds: number[] = [];
@Input() loadingStrategy: 'pagination' | 'loadMore' | 'infiniteScroll' = 'pagination';
@Input() hasMore = false;
@Input() searchQuery = '';
@Input() queryTooShort = false;
@Input() minSearchLength = 3;
@Output() pageChange = new EventEmitter<number>();
@Output() loadMore = new EventEmitter<void>();

View File

@@ -31,7 +31,7 @@
@for (saved of savedSearches(); track saved.id) {
<div class="catalog-saved-chip">
<button type="button" (click)="useSavedSearch(saved.id)">{{ saved.name }}</button>
<button type="button" class="catalog-saved-chip-remove" [attr.aria-label]="'catalog.removeSavedSearch' | translate" (click)="removeSavedSearch(saved.id)">×</button>
<button type="button" class="catalog-saved-chip-remove" [attr.aria-label]="'catalog.removeSavedSearch' | translate" (click)="removeSavedSearch(saved.id)"><app-icon name="x" [size]="14" /></button>
</div>
}
</div>
@@ -172,7 +172,10 @@
[showAvailability]="features().availability"
[showShareAction]="features().share"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()" />
[comparedIds]="comparedIds()"
[searchQuery]="state().search"
[queryTooShort]="isQueryTooShort()"
[minSearchLength]="minSearchLength" />
} @else if (isEmptyCategoryState()) {
<app-catalog-empty-state
variant="category"
@@ -218,6 +221,9 @@
[showShareAction]="features().share"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()"
[searchQuery]="state().search"
[queryTooShort]="isQueryTooShort()"
[minSearchLength]="minSearchLength"
(pageChange)="onResultsPageChange($event)"
(loadMore)="loadNextPage()"
(productSelected)="selectProduct($event)"
@@ -235,7 +241,7 @@
<aside class="catalog-filter-drawer" role="dialog" [attr.aria-label]="'catalog.filtersDrawerAria' | translate" aria-modal="true" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
<header class="catalog-filter-drawer-head">
<h2>{{ 'catalog.filtersTitle' | translate }}</h2>
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeFilterDrawer()">×</button>
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeFilterDrawer()"><app-icon name="x" [size]="18" /></button>
</header>
<div class="catalog-filter-drawer-body">
@@ -257,7 +263,7 @@
<section class="catalog-sort-sheet" role="dialog" [attr.aria-label]="'catalog.sortSheetAria' | translate" aria-modal="true" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
<header class="catalog-sort-sheet-head">
<h2>{{ 'catalog.sortBy' | translate }}</h2>
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeSortSheet()">×</button>
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeSortSheet()"><app-icon name="x" [size]="18" /></button>
</header>
<div class="catalog-sort-sheet-options">
@@ -275,7 +281,7 @@
<section class="catalog-sort-sheet catalog-grid-sheet" role="dialog" [attr.aria-label]="'catalog.gridCycle' | translate" aria-modal="true" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
<header class="catalog-sort-sheet-head">
<h2>{{ 'catalog.gridCycle' | translate }}</h2>
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeGridSheet()">×</button>
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeGridSheet()"><app-icon name="x" [size]="18" /></button>
</header>
<div class="catalog-sort-sheet-options">

View File

@@ -31,6 +31,7 @@ import { CatalogSortingControlComponent } from '../components/sorting-control/so
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
import { ProductShareService } from '../../user-experience/services/product-share.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
@@ -54,7 +55,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
CatalogCategoryGridComponent,
EmptyStateComponent,
SkeletonComponent,
ButtonComponent
ButtonComponent,
IconComponent
],
templateUrl: './catalog-container.component.html',
styleUrls: ['./catalog-container.component.scss'],
@@ -106,6 +108,11 @@ export class CatalogContainerComponent {
readonly sortSheetOpen = signal(false);
readonly gridSheetOpen = signal(false);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
readonly minSearchLength = 3;
readonly isQueryTooShort = computed(() => {
const length = this.state().search.trim().length;
return length > 0 && length < this.minSearchLength;
});
readonly offlineState = computed(() => !this.online());
readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
readonly isMobile = computed(() => this.viewportWidth() <= 767);