style(catalog): polish responsive UI

This commit is contained in:
sdarbinyan
2026-07-09 02:43:39 +04:00
parent 8d652c8259
commit 6409a91cb0
11 changed files with 568 additions and 205 deletions

View File

@@ -6,121 +6,139 @@
@for (filter of definitions; track filter.id) {
@if (filter.enabled) {
<section class="filter-group">
<h4>{{ filter.label }}</h4>
<button
type="button"
class="filter-group-toggle"
[class.collapsed]="isCollapsed(filter.id)"
[attr.aria-expanded]="!isCollapsed(filter.id)"
(click)="toggleGroup(filter.id)">
<span>{{ filter.label }}</span>
<span class="filter-group-chevron" aria-hidden="true"></span>
</button>
@if (filter.type === 'checkbox' || filter.type === 'availability') {
<div class="options">
@for (option of filter.options; track option.id) {
<label class="option-check">
<div class="filter-group-body" [class.collapsed]="isCollapsed(filter.id)">
@if (filter.type === 'checkbox' || filter.type === 'availability') {
<div class="options">
@for (option of filter.options; track option.id) {
<label class="option-check">
<input
type="checkbox"
[checked]="isSelected(filter.id, option.value)"
(change)="toggleOption(filter.id, option.value)" />
@if (filter.type === 'availability') {
<span class="availability-dot" [attr.data-availability]="option.availability ?? option.value" aria-hidden="true"></span>
}
<span>{{ option.label }}</span>
@if (option.count != null) {
<small>{{ option.count }}</small>
}
</label>
}
</div>
}
@if (filter.type === 'radio') {
<div class="options">
@for (option of filter.options; track option.id) {
<label class="option-check">
<input
type="radio"
[name]="'radio-' + filter.id"
[checked]="isSelected(filter.id, option.value)"
(change)="selectSingleOption(filter.id, option.value)" />
<span>{{ option.label }}</span>
</label>
}
</div>
}
@if (filter.type === 'range') {
<div class="range-inputs">
<label class="range-field">
<span>{{ 'catalog.from' | translate }}</span>
<input
type="checkbox"
[checked]="isSelected(filter.id, option.value)"
(change)="toggleOption(filter.id, option.value)" />
@if (filter.type === 'availability') {
<span class="availability-dot" [attr.data-availability]="option.availability ?? option.value" aria-hidden="true"></span>
}
<span>{{ option.label }}</span>
@if (option.count != null) {
<small>{{ option.count }}</small>
}
type="number"
[value]="state.ranges[filter.id]?.min ?? ''"
(input)="updateRange(filter.id, 'min', $any($event.target).value)"
[placeholder]="'catalog.minValue' | translate:{ value: filter.min ?? '' }" />
</label>
}
</div>
}
@if (filter.type === 'radio') {
<div class="options">
@for (option of filter.options; track option.id) {
<label class="option-check">
<label class="range-field">
<span>{{ 'catalog.to' | translate }}</span>
<input
type="radio"
[name]="'radio-' + filter.id"
[checked]="isSelected(filter.id, option.value)"
(change)="selectSingleOption(filter.id, option.value)" />
<span>{{ option.label }}</span>
type="number"
[value]="state.ranges[filter.id]?.max ?? ''"
(input)="updateRange(filter.id, 'max', $any($event.target).value)"
[placeholder]="'catalog.maxValue' | translate:{ value: filter.max ?? '' }" />
</label>
}
</div>
}
</div>
}
@if (filter.type === 'range') {
<div class="range-inputs">
<input
type="number"
[value]="state.ranges[filter.id]?.min ?? ''"
(input)="updateRange(filter.id, 'min', $any($event.target).value)"
[placeholder]="'catalog.minValue' | translate:{ value: filter.min ?? '' }" />
<input
type="number"
[value]="state.ranges[filter.id]?.max ?? ''"
(input)="updateRange(filter.id, 'max', $any($event.target).value)"
[placeholder]="'catalog.maxValue' | translate:{ value: filter.max ?? '' }" />
</div>
}
@if (filter.type === 'slider') {
<div class="slider-inputs">
<input
type="range"
[min]="filter.min ?? 0"
[max]="filter.max ?? 1000"
[step]="filter.step ?? 1"
[value]="state.ranges[filter.id]?.max ?? filter.max ?? 1000"
(input)="updateSlider(filter.id, $any($event.target).value)" />
<span>{{ state.ranges[filter.id]?.max ?? filter.max ?? 0 }}</span>
</div>
}
@if (filter.type === 'slider') {
<div class="slider-inputs">
<input
type="range"
[min]="filter.min ?? 0"
[max]="filter.max ?? 1000"
[step]="filter.step ?? 1"
[value]="state.ranges[filter.id]?.max ?? filter.max ?? 1000"
(input)="updateSlider(filter.id, $any($event.target).value)" />
<span>{{ state.ranges[filter.id]?.max ?? filter.max ?? 0 }}</span>
</div>
}
@if (filter.type === 'color') {
<div class="color-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="color-swatch"
[class.active]="isSelected(filter.id, option.value)"
[style.background]="option.colorHex ?? '#94a3b8'"
[attr.aria-label]="option.label"
(click)="toggleOption(filter.id, option.value)"></button>
}
</div>
}
@if (filter.type === 'color') {
<div class="color-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="color-swatch"
[class.active]="isSelected(filter.id, option.value)"
[style.background]="option.colorHex ?? '#94a3b8'"
[attr.aria-label]="option.label"
(click)="toggleOption(filter.id, option.value)"></button>
}
</div>
}
@if (filter.type === 'size') {
<div class="size-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="size-chip"
[class.active]="isSelected(filter.id, option.value)"
(click)="toggleOption(filter.id, option.value)">{{ option.label }}</button>
}
</div>
}
@if (filter.type === 'size') {
<div class="size-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="size-chip"
[class.active]="isSelected(filter.id, option.value)"
(click)="toggleOption(filter.id, option.value)">{{ option.label }}</button>
}
</div>
}
@if (filter.type === 'rating') {
<div class="rating-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="rating-chip"
[class.active]="isSelected(filter.id, option.value)"
(click)="toggleOption(filter.id, option.value)">
<span aria-hidden="true"></span>
<span>{{ option.label }}</span>
</button>
}
</div>
}
@if (filter.type === 'rating') {
<div class="rating-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="rating-chip"
[class.active]="isSelected(filter.id, option.value)"
(click)="toggleOption(filter.id, option.value)">
<span aria-hidden="true"></span>
<span>{{ option.label }}</span>
</button>
}
</div>
}
@if (filter.type === 'toggle') {
<label class="option-check">
<input
type="checkbox"
[checked]="state.toggles[filter.id]"
(change)="updateToggle(filter.id, $any($event.target).checked)" />
<span>{{ 'catalog.enabled' | translate }}</span>
</label>
}
@if (filter.type === 'toggle') {
<label class="option-check">
<input
type="checkbox"
[checked]="state.toggles[filter.id]"
(change)="updateToggle(filter.id, $any($event.target).checked)" />
<span>{{ 'catalog.enabled' | translate }}</span>
</label>
}
</div>
</section>
}
}

View File

@@ -1,7 +1,7 @@
.catalog-filters {
padding: 14px;
padding: 16px;
display: grid;
gap: 14px;
gap: 16px;
align-content: start;
}
@@ -19,29 +19,107 @@
.filter-group {
display: grid;
gap: 8px;
gap: 12px;
border-top: 1px solid var(--border-color);
padding-top: 10px;
padding-top: 12px;
}
.filter-group h4 {
margin: 0;
font-size: 0.95rem;
.filter-group-toggle {
min-height: 36px;
border: 0;
background: transparent;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 0.95rem;
font-weight: 700;
text-align: left;
cursor: pointer;
padding: 0;
}
.filter-group-chevron {
color: var(--text-secondary);
font-size: 0.9rem;
transition: transform 0.2s ease;
}
.filter-group-toggle.collapsed .filter-group-chevron {
transform: rotate(-90deg);
}
.filter-group-body {
display: grid;
gap: 12px;
grid-template-rows: 1fr;
overflow: hidden;
transition: grid-template-rows 0.2s ease, opacity 0.2s ease;
opacity: 1;
}
.filter-group-body.collapsed {
grid-template-rows: 0fr;
opacity: 0;
}
.filter-group-body > * {
min-height: 0;
}
.filter-group-toggle:focus-visible,
.option-check input:focus-visible,
.range-inputs input:focus-visible,
.slider-inputs input:focus-visible,
.size-chip:focus-visible,
.rating-chip:focus-visible,
.color-swatch:focus-visible {
outline: 3px solid color-mix(in srgb, var(--primary-color) 28%, white);
outline-offset: 2px;
}
.filter-group h4,
.range-field span {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-secondary);
}
.range-field {
display: grid;
gap: 8px;
}
.range-field input {
width: 100%;
}
.options {
display: grid;
gap: 6px;
gap: 8px;
}
.option-check {
display: grid;
grid-template-columns: auto auto 1fr auto;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
gap: 10px;
color: var(--text-primary);
min-height: 32px;
}
.option-check small {
color: var(--text-secondary);
font-size: 0.9rem;
}
.option-check input {
width: 16px;
height: 16px;
accent-color: var(--primary-color);
}
.availability-dot {
@@ -65,20 +143,23 @@
.range-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
grid-template-columns: 1fr;
gap: 12px;
}
.range-inputs input {
min-height: 34px;
min-height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0 8px;
padding: 0 12px;
font: inherit;
color: var(--text-primary);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.slider-inputs {
display: grid;
gap: 8px;
gap: 12px;
}
.slider-inputs input {
@@ -87,7 +168,8 @@
.slider-inputs span {
color: var(--text-secondary);
font-size: 0.85rem;
font-size: 0.88rem;
font-weight: 600;
}
.color-options,
@@ -99,28 +181,35 @@
}
.color-swatch {
width: 24px;
height: 24px;
width: 30px;
height: 30px;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.color-swatch.active {
border-color: var(--text-primary);
}
.color-swatch:hover {
transform: translateY(-1px);
}
.size-chip,
.rating-chip {
min-height: 30px;
min-height: 34px;
border: 1px solid var(--border-color);
border-radius: 999px;
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 10px;
color: var(--text-primary);
padding: 0 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease;
}
.size-chip.active,
@@ -129,15 +218,26 @@
background: color-mix(in srgb, var(--primary-color) 8%, white);
}
.size-chip:hover,
.rating-chip:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
}
.size-chip:disabled,
.rating-chip:disabled,
.color-swatch:disabled,
.range-inputs input:disabled,
.slider-inputs input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 1024px) {
.catalog-filters {
gap: 12px;
}
.range-inputs {
grid-template-columns: 1fr;
}
.option-check {
grid-template-columns: auto auto 1fr;
}

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@@ -17,6 +17,19 @@ export class CatalogFiltersPanelComponent {
@Output() stateChange = new EventEmitter<SearchFilterState>();
readonly collapsed = signal<Record<string, boolean>>({});
toggleGroup(filterId: string): void {
this.collapsed.update(current => ({
...current,
[filterId]: !current[filterId],
}));
}
isCollapsed(filterId: string): boolean {
return this.collapsed()[filterId] ?? false;
}
isSelected(filterId: string, optionValue: string): boolean {
return (this.state.values[filterId] ?? []).includes(optionValue);
}

View File

@@ -6,23 +6,24 @@
}
.layout-btn {
min-height: 36px;
min-height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
font-weight: 700;
padding: 0 10px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
}
.layout-btn svg {
width: 16px;
height: 16px;
width: 18px;
height: 18px;
flex: 0 0 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
@@ -30,7 +31,8 @@
.layout-btn.active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 10%, white);
background: color-mix(in srgb, var(--primary-color) 12%, white);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 30%, white);
}
.layout-btn:hover {
@@ -38,6 +40,11 @@
border-color: var(--primary-color);
}
.layout-btn:focus-visible {
outline: 3px solid color-mix(in srgb, var(--primary-color) 28%, white);
outline-offset: 2px;
}
@media (max-width: 640px) {
.catalog-layout-switcher {
width: 100%;

View File

@@ -1,6 +1,6 @@
.catalog-search-results {
display: grid;
gap: 14px;
gap: 16px;
}
.results-head {
@@ -8,7 +8,8 @@
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
gap: 12px;
min-width: 0;
}
.results-head strong {
@@ -22,13 +23,16 @@
.results-skeletons {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
gap: 16px;
}
.skeleton-card {
min-height: 220px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
min-height: 340px;
border: 1px solid #d3dad9;
border-radius: 13px;
background:
linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%) 16px 16px / calc(100% - 32px) calc(100% - 32px) no-repeat,
#f5f3f9;
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
@@ -55,11 +59,11 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: 12px;
}
.results-pager button {
min-height: 36px;
min-height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
@@ -68,6 +72,11 @@
transition: border-color 0.2s ease, transform 0.2s ease;
}
.results-pager button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--primary-color) 28%, white);
outline-offset: 2px;
}
.results-pager button:hover:not(:disabled) {
border-color: var(--primary-color);
transform: translateY(-1px);

View File

@@ -107,9 +107,9 @@
<div class="catalog-results-panel">
@if (showMobileToolbar()) {
<div class="catalog-mobile-toolbar" [attr.aria-label]="'catalog.mobileToolbarAria' | translate">
<button type="button" class="catalog-mobile-toolbar-btn" (click)="openFilterDrawer()">{{ 'catalog.filtersTitle' | translate }}</button>
<button type="button" class="catalog-mobile-toolbar-btn" (click)="openSortSheet()">{{ 'catalog.sortBy' | translate }}</button>
<button type="button" class="catalog-mobile-toolbar-btn" (click)="cycleLayout()">{{ 'catalog.gridCycle' | translate }}</button>
<button type="button" class="catalog-mobile-toolbar-btn" [attr.aria-label]="'catalog.filtersTitle' | translate" (click)="openFilterDrawer()">{{ 'catalog.filtersTitle' | translate }}</button>
<button type="button" class="catalog-mobile-toolbar-btn" [attr.aria-label]="'catalog.sortBy' | translate" (click)="openSortSheet()">{{ 'catalog.sortBy' | translate }}</button>
<button type="button" class="catalog-mobile-toolbar-btn" [attr.aria-label]="'catalog.gridCycle' | translate" (click)="openGridSheet()">{{ 'catalog.gridCycle' | translate }}</button>
</div>
}
@@ -249,5 +249,23 @@
</div>
</section>
}
@if (isMobile() && gridSheetOpen()) {
<div class="catalog-overlay" (click)="closeGridSheet()" aria-hidden="true"></div>
<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>
</header>
<div class="catalog-sort-sheet-options">
@for (layout of availableLayouts; track layout) {
<button type="button" class="catalog-sort-option" [class.active]="state().layout === layout" (click)="selectLayoutFromSheet(layout)">
{{ layoutLabelKey(layout) | translate }}
</button>
}
</div>
</section>
}
}
</main>

View File

@@ -3,13 +3,13 @@
margin: 0 auto;
padding: 24px;
color: #1e3c38;
overflow-x: clip;
overflow-x: hidden;
}
.catalog-header {
display: flex;
flex-direction: column;
gap: 14px;
gap: 16px;
margin-bottom: 24px;
}
@@ -81,7 +81,7 @@
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
align-items: start;
gap: 16px;
gap: 24px;
}
.catalog-left-panel {
@@ -91,25 +91,26 @@
.catalog-results-panel {
display: grid;
gap: 14px;
gap: 16px;
min-width: 0;
}
.catalog-tools-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.catalog-mobile-toolbar {
position: sticky;
bottom: 10px;
bottom: 12px;
z-index: 5;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 8px;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-primary) 92%, white);
@@ -118,14 +119,22 @@
.catalog-mobile-toolbar-btn,
.catalog-open-filters-btn {
min-height: 40px;
min-height: 42px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
padding: 0 10px;
padding: 0 12px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease;
}
.catalog-mobile-toolbar-btn:hover,
.catalog-open-filters-btn:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, white);
transform: translateY(-1px);
}
.catalog-open-filters-btn {
@@ -135,12 +144,12 @@
.catalog-sort-reset-group {
display: inline-flex;
align-items: center;
gap: 10px;
gap: 12px;
flex-wrap: wrap;
}
.catalog-reset-btn {
min-height: 36px;
min-height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: transparent;
@@ -158,7 +167,7 @@
}
.catalog-save-search-btn {
min-height: 36px;
min-height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
@@ -169,6 +178,17 @@
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
.catalog-reset-btn:focus-visible,
.catalog-save-search-btn:focus-visible,
.catalog-mobile-toolbar-btn:focus-visible,
.catalog-open-filters-btn:focus-visible,
.catalog-sort-option:focus-visible,
.catalog-icon-btn:focus-visible,
.catalog-apply-btn:focus-visible {
outline: 3px solid color-mix(in srgb, var(--primary-color) 28%, white);
outline-offset: 2px;
}
.catalog-overlay {
position: fixed;
inset: 0;
@@ -188,6 +208,7 @@
grid-template-rows: auto 1fr auto;
border-left: 1px solid var(--border-color);
box-shadow: -12px 0 28px rgba(15, 23, 42, 0.14);
animation: catalog-drawer-in 0.2s ease-out;
}
.catalog-filter-drawer-head,
@@ -221,19 +242,23 @@
.catalog-filter-drawer-body {
overflow: auto;
padding: 12px;
padding: 16px;
padding-bottom: 84px;
}
.catalog-filter-drawer-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--border-color);
position: sticky;
bottom: 0;
background: var(--bg-primary);
}
.catalog-apply-btn {
min-height: 40px;
min-height: 42px;
border: 0;
border-radius: var(--radius-sm);
background: var(--primary-color);
@@ -256,16 +281,17 @@
box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.16);
max-height: 80vh;
overflow: auto;
animation: catalog-sheet-in 0.2s ease-out;
}
.catalog-sort-sheet-options {
display: grid;
gap: 6px;
padding: 12px;
gap: 8px;
padding: 16px;
}
.catalog-sort-option {
min-height: 42px;
min-height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
@@ -274,6 +300,7 @@
padding: 0 12px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease;
}
.catalog-sort-option.active {
@@ -281,6 +308,15 @@
background: color-mix(in srgb, var(--primary-color) 10%, white);
}
.catalog-sort-option:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
}
.catalog-grid-sheet .catalog-sort-sheet-options {
grid-template-columns: 1fr;
}
.catalog-save-search-btn:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
@@ -305,11 +341,11 @@
.subcategory-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 12px;
}
.subcategory-chip {
min-height: 32px;
min-height: 36px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
@@ -361,14 +397,14 @@
.catalog-loading {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 24px;
gap: 16px;
}
.catalog-skeleton-card {
min-height: 260px;
min-height: 340px;
border: 1px solid #d3dad9;
border-radius: 13px;
padding: 12px;
padding: 16px;
background: #f5f3f9;
}
@@ -381,13 +417,13 @@
}
.catalog-skeleton-image {
aspect-ratio: 4 / 3;
margin-bottom: 14px;
aspect-ratio: 1 / 1;
margin-bottom: 16px;
}
.catalog-skeleton-line {
height: 14px;
margin-top: 10px;
margin-top: 12px;
}
.catalog-skeleton-title {
@@ -442,6 +478,28 @@
100% { background-position: -200% 0; }
}
@keyframes catalog-sheet-in {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes catalog-drawer-in {
from {
transform: translateX(12px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (max-width: 640px) {
.catalog-page {
padding: 16px;
@@ -454,7 +512,7 @@
.catalog-products-section {
grid-template-columns: 1fr;
gap: 14px;
gap: 16px;
}
.catalog-left-panel {
@@ -489,6 +547,16 @@
font-size: 1.5rem;
}
.catalog-mobile-toolbar {
bottom: 8px;
gap: 8px;
padding: 8px;
}
.catalog-mobile-toolbar-btn {
min-height: 40px;
}
.catalog-section-heading h1 {
font-size: 1.35rem;
}

View File

@@ -93,6 +93,7 @@ export class CatalogContainerComponent {
readonly viewportWidth = signal(typeof window !== 'undefined' ? window.innerWidth : 1280);
readonly filterDrawerOpen = signal(false);
readonly sortSheetOpen = signal(false);
readonly gridSheetOpen = signal(false);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
readonly isMobile = computed(() => this.viewportWidth() <= 767);
@@ -101,7 +102,7 @@ export class CatalogContainerComponent {
readonly isFilteredEmptyState = computed(() => this.viewMode() === 'products' && !this.loadingProducts() && this.rawProducts().length > 0 && this.products().length === 0);
readonly showDesktopFilters = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.isDrawerLayout());
readonly showMobileToolbar = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && this.isMobile());
readonly showCatalogTools = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.loadingProducts());
readonly showCatalogTools = computed(() => this.viewMode() === 'products' && this.products().length > 0 && !this.loadingProducts());
readonly mobileSortOptions = computed(() => {
const allowed = new Set(['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular']);
const options = this.sortDefinitions().filter(option => allowed.has(option.id));
@@ -408,6 +409,10 @@ export class CatalogContainerComponent {
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.gridSheetOpen()) {
this.closeGridSheet();
}
if (this.sortSheetOpen()) {
this.closeSortSheet();
}
@@ -471,6 +476,8 @@ export class CatalogContainerComponent {
}
openFilterDrawer(): void {
this.closeSortSheet();
this.closeGridSheet();
this.filterDrawerOpen.set(true);
}
@@ -483,6 +490,8 @@ export class CatalogContainerComponent {
}
openSortSheet(): void {
this.closeFilterDrawer();
this.closeGridSheet();
this.sortSheetOpen.set(true);
}
@@ -495,6 +504,35 @@ export class CatalogContainerComponent {
this.closeSortSheet();
}
openGridSheet(): void {
this.closeFilterDrawer();
this.closeSortSheet();
this.gridSheetOpen.set(true);
}
closeGridSheet(): void {
this.gridSheetOpen.set(false);
}
selectLayoutFromSheet(layout: CatalogLayoutMode): void {
this.changeLayout(layout);
this.closeGridSheet();
}
layoutLabelKey(layout: CatalogLayoutMode): string {
switch (layout) {
case 'grid':
return 'catalog.layout.grid';
case 'large-grid':
return 'catalog.layout.largeGrid';
case 'compact-grid':
return 'catalog.layout.compactGrid';
case 'list':
default:
return 'catalog.layout.list';
}
}
browseCategories(): void {
this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`]);
}