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:
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user