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>
|
</div>
|
||||||
|
|
||||||
@if (loading) {
|
@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) {
|
@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>
|
</div>
|
||||||
} @else if (products.length > 0) {
|
} @else if (products.length > 0) {
|
||||||
@@ -28,9 +32,26 @@
|
|||||||
(compareToggled)="compareToggled.emit($event)"
|
(compareToggled)="compareToggled.emit($event)"
|
||||||
(shareRequested)="shareRequested.emit($event)" />
|
(shareRequested)="shareRequested.emit($event)" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="empty-state card">
|
<div class="results-empty-state card">
|
||||||
<h3>{{ 'catalog.emptyResultsTitle' | translate }}</h3>
|
@if (queryTooShort) {
|
||||||
<p>{{ 'catalog.emptyResultsDescription' | translate }}</p>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,31 +28,18 @@
|
|||||||
|
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
min-height: 340px;
|
min-height: 340px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg, 13px);
|
border-radius: var(--radius-lg, 13px);
|
||||||
background:
|
background: var(--bg-secondary);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.results-empty-state {
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
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 {
|
.results-pager {
|
||||||
@@ -109,7 +96,3 @@
|
|||||||
text-align: center;
|
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 { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
|
||||||
import { CatalogProductGridComponent } from '../product-grid/product-grid.component';
|
import { CatalogProductGridComponent } from '../product-grid/product-grid.component';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
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({
|
@Component({
|
||||||
selector: 'app-catalog-search-results',
|
selector: 'app-catalog-search-results',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CatalogProductGridComponent, TranslatePipe],
|
imports: [CatalogProductGridComponent, TranslatePipe, EmptyStateComponent, IconComponent, SkeletonComponent],
|
||||||
templateUrl: './search-results.component.html',
|
templateUrl: './search-results.component.html',
|
||||||
styleUrls: ['./search-results.component.scss'],
|
styleUrls: ['./search-results.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -28,6 +31,9 @@ export class CatalogSearchResultsComponent {
|
|||||||
@Input() comparedIds: number[] = [];
|
@Input() comparedIds: number[] = [];
|
||||||
@Input() loadingStrategy: 'pagination' | 'loadMore' | 'infiniteScroll' = 'pagination';
|
@Input() loadingStrategy: 'pagination' | 'loadMore' | 'infiniteScroll' = 'pagination';
|
||||||
@Input() hasMore = false;
|
@Input() hasMore = false;
|
||||||
|
@Input() searchQuery = '';
|
||||||
|
@Input() queryTooShort = false;
|
||||||
|
@Input() minSearchLength = 3;
|
||||||
|
|
||||||
@Output() pageChange = new EventEmitter<number>();
|
@Output() pageChange = new EventEmitter<number>();
|
||||||
@Output() loadMore = new EventEmitter<void>();
|
@Output() loadMore = new EventEmitter<void>();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
@for (saved of savedSearches(); track saved.id) {
|
@for (saved of savedSearches(); track saved.id) {
|
||||||
<div class="catalog-saved-chip">
|
<div class="catalog-saved-chip">
|
||||||
<button type="button" (click)="useSavedSearch(saved.id)">{{ saved.name }}</button>
|
<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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +172,10 @@
|
|||||||
[showAvailability]="features().availability"
|
[showAvailability]="features().availability"
|
||||||
[showShareAction]="features().share"
|
[showShareAction]="features().share"
|
||||||
[favoriteIds]="favoriteIds()"
|
[favoriteIds]="favoriteIds()"
|
||||||
[comparedIds]="comparedIds()" />
|
[comparedIds]="comparedIds()"
|
||||||
|
[searchQuery]="state().search"
|
||||||
|
[queryTooShort]="isQueryTooShort()"
|
||||||
|
[minSearchLength]="minSearchLength" />
|
||||||
} @else if (isEmptyCategoryState()) {
|
} @else if (isEmptyCategoryState()) {
|
||||||
<app-catalog-empty-state
|
<app-catalog-empty-state
|
||||||
variant="category"
|
variant="category"
|
||||||
@@ -218,6 +221,9 @@
|
|||||||
[showShareAction]="features().share"
|
[showShareAction]="features().share"
|
||||||
[favoriteIds]="favoriteIds()"
|
[favoriteIds]="favoriteIds()"
|
||||||
[comparedIds]="comparedIds()"
|
[comparedIds]="comparedIds()"
|
||||||
|
[searchQuery]="state().search"
|
||||||
|
[queryTooShort]="isQueryTooShort()"
|
||||||
|
[minSearchLength]="minSearchLength"
|
||||||
(pageChange)="onResultsPageChange($event)"
|
(pageChange)="onResultsPageChange($event)"
|
||||||
(loadMore)="loadNextPage()"
|
(loadMore)="loadNextPage()"
|
||||||
(productSelected)="selectProduct($event)"
|
(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">
|
<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">
|
<header class="catalog-filter-drawer-head">
|
||||||
<h2>{{ 'catalog.filtersTitle' | translate }}</h2>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="catalog-filter-drawer-body">
|
<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">
|
<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">
|
<header class="catalog-sort-sheet-head">
|
||||||
<h2>{{ 'catalog.sortBy' | translate }}</h2>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="catalog-sort-sheet-options">
|
<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">
|
<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">
|
<header class="catalog-sort-sheet-head">
|
||||||
<h2>{{ 'catalog.gridCycle' | translate }}</h2>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="catalog-sort-sheet-options">
|
<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 { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { ButtonComponent } from '../../../../shared/ui/button/button.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 { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
|
||||||
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||||
@@ -54,7 +55,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
|
|||||||
CatalogCategoryGridComponent,
|
CatalogCategoryGridComponent,
|
||||||
EmptyStateComponent,
|
EmptyStateComponent,
|
||||||
SkeletonComponent,
|
SkeletonComponent,
|
||||||
ButtonComponent
|
ButtonComponent,
|
||||||
|
IconComponent
|
||||||
],
|
],
|
||||||
templateUrl: './catalog-container.component.html',
|
templateUrl: './catalog-container.component.html',
|
||||||
styleUrls: ['./catalog-container.component.scss'],
|
styleUrls: ['./catalog-container.component.scss'],
|
||||||
@@ -106,6 +108,11 @@ export class CatalogContainerComponent {
|
|||||||
readonly sortSheetOpen = signal(false);
|
readonly sortSheetOpen = signal(false);
|
||||||
readonly gridSheetOpen = signal(false);
|
readonly gridSheetOpen = signal(false);
|
||||||
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
|
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 offlineState = computed(() => !this.online());
|
||||||
readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
|
readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
|
||||||
readonly isMobile = computed(() => this.viewportWidth() <= 767);
|
readonly isMobile = computed(() => this.viewportWidth() <= 767);
|
||||||
|
|||||||
Reference in New Issue
Block a user