style(catalog): polish responsive UI
This commit is contained in:
@@ -207,6 +207,71 @@ Catalog spacing and controls were polished for desktop/tablet/mobile:
|
|||||||
- drawer/sheet interaction surfaces
|
- drawer/sheet interaction surfaces
|
||||||
- horizontal overflow prevention
|
- horizontal overflow prevention
|
||||||
|
|
||||||
|
## Sprint UI Polish (Visual Only)
|
||||||
|
|
||||||
|
This sprint applies visual and responsive UX polish only. No facade contracts, business logic, API contracts, runtime/bootstrap architecture, or widget contracts were changed.
|
||||||
|
|
||||||
|
### Desktop Layout
|
||||||
|
|
||||||
|
- Catalog products section uses a cleaner two-column structure with consistent spacing tokens (8/12/16/24/32).
|
||||||
|
- Filters panel remains sticky on desktop and uses collapsible sections with smooth expand/collapse animation.
|
||||||
|
- Product cards keep consistent image height/aspect ratio and improved vertical rhythm between image/title/rating/price/actions.
|
||||||
|
- Product action controls are vertical floating circles in the image top-right zone with fixed spacing and no overlap.
|
||||||
|
|
||||||
|
### Tablet Layout
|
||||||
|
|
||||||
|
- Sidebar filters transition into drawer interaction for better content width.
|
||||||
|
- Sort and layout controls retain consistent sizing and spacing.
|
||||||
|
- Grid/list results avoid horizontal overflow and preserve button/input containment.
|
||||||
|
|
||||||
|
### Mobile Layout
|
||||||
|
|
||||||
|
- Permanent sidebar is hidden.
|
||||||
|
- Sticky toolbar provides three entry points: Filters, Sort, Grid.
|
||||||
|
- Filters open in drawer form with scrollable content and fixed bottom actions.
|
||||||
|
- Sort and Grid open bottom-sheet style dialogs.
|
||||||
|
- Focus states and keyboard dismissal (`Esc`) are preserved for all overlays.
|
||||||
|
|
||||||
|
### Grid Types
|
||||||
|
|
||||||
|
- `grid`
|
||||||
|
- `large-grid`
|
||||||
|
- `compact-grid`
|
||||||
|
- `list`
|
||||||
|
|
||||||
|
All grid switch icons are normalized in size and selected state is visually highlighted.
|
||||||
|
|
||||||
|
### Filter Drawer and Sections
|
||||||
|
|
||||||
|
- Filter groups (Price, Availability, Rating, Brand, etc.) are collapsible.
|
||||||
|
- Range inputs are stacked vertically (`From`, `To`) with full-width controls and 12px+ spacing.
|
||||||
|
- Slider remains below price inputs for predictable scan order.
|
||||||
|
|
||||||
|
### Product Card Anatomy
|
||||||
|
|
||||||
|
- Image area: square ratio, `object-fit: contain`, padded image content.
|
||||||
|
- Status elements (discount/stock/badges) positioned to avoid action collisions.
|
||||||
|
- Actions: top-right vertical controls with equal circular dimensions.
|
||||||
|
- Content: title, optional description, rating, pricing, stock indicator, CTA.
|
||||||
|
|
||||||
|
### Animations
|
||||||
|
|
||||||
|
- Card hover: subtle elevation + `translateY(-2px)`.
|
||||||
|
- Button/selector transitions: ~180-200ms.
|
||||||
|
- Filter group expand/collapse: smooth height/opacity transition.
|
||||||
|
- Drawer and sheet overlays: subtle slide/fade entrance.
|
||||||
|
|
||||||
|
### Empty and Loading States
|
||||||
|
|
||||||
|
- Empty results state keeps friendly message and action while hiding non-essential catalog controls when no products are rendered.
|
||||||
|
- Skeletons for cards/results keep stable heights to reduce layout shift.
|
||||||
|
|
||||||
|
### Accessibility Notes
|
||||||
|
|
||||||
|
- Added/standardized visible `:focus-visible` outlines for interactive elements.
|
||||||
|
- Product quick action controls now expose aria labels.
|
||||||
|
- Modal/drawer interactions continue to use dialog semantics and focus trap.
|
||||||
|
|
||||||
### Localization and Accessibility
|
### Localization and Accessibility
|
||||||
|
|
||||||
- New strings for empty states, drawer/sheet UI, and toolbar were added to all languages:
|
- New strings for empty states, drawer/sheet UI, and toolbar were added to all languages:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="product-image">
|
<div class="product-image">
|
||||||
<img [src]="getMainImage(item)" [alt]="title || item.name" loading="lazy" decoding="async" width="300" height="300" />
|
<img [src]="getMainImage(item)" [alt]="title || item.name" loading="lazy" decoding="async" width="300" height="300" />
|
||||||
@if (showDiscountBadge && item.discount > 0) {
|
@if (showDiscountBadge && item.discount > 0) {
|
||||||
<div class="discount-badge">-{{ item.discount }}%</div>
|
<div class="discount-badge" [attr.aria-label]="'Discount ' + item.discount + '%'">-{{ item.discount }}%</div>
|
||||||
}
|
}
|
||||||
@if (showStock && item.remainings) {
|
@if (showStock && item.remainings) {
|
||||||
<span class="stock-badge" [class.out]="item.remainings === 'out'">{{ item.remainings }}</span>
|
<span class="stock-badge" [class.out]="item.remainings === 'out'">{{ item.remainings }}</span>
|
||||||
@@ -12,16 +12,16 @@
|
|||||||
@if (showFavoriteControl() || showCompareControl() || showShareAction || showQuickViewPlaceholder) {
|
@if (showFavoriteControl() || showCompareControl() || showShareAction || showQuickViewPlaceholder) {
|
||||||
<div class="product-actions-overlay">
|
<div class="product-actions-overlay">
|
||||||
@if (showFavoriteControl()) {
|
@if (showFavoriteControl()) {
|
||||||
<button type="button" class="product-action-btn product-action-favorite" [class.active]="isFavorite" (click)="onFavoritePlaceholder($event)">❤</button>
|
<button type="button" class="product-action-btn product-action-favorite" [attr.aria-label]="'catalog.favorite' | translate" [class.active]="isFavorite" (click)="onFavoritePlaceholder($event)">❤</button>
|
||||||
}
|
}
|
||||||
@if (showCompareControl()) {
|
@if (showCompareControl()) {
|
||||||
<button type="button" class="product-action-btn" [class.active]="isCompared" (click)="onComparePlaceholder($event)">⇄</button>
|
<button type="button" class="product-action-btn" [attr.aria-label]="'catalog.compare' | translate" [class.active]="isCompared" (click)="onComparePlaceholder($event)">⇄</button>
|
||||||
}
|
}
|
||||||
@if (showShareAction) {
|
@if (showShareAction) {
|
||||||
<button type="button" class="product-action-btn" (click)="onShare($event)">↗</button>
|
<button type="button" class="product-action-btn" [attr.aria-label]="'catalog.share' | translate" (click)="onShare($event)">↗</button>
|
||||||
}
|
}
|
||||||
@if (showQuickViewPlaceholder) {
|
@if (showQuickViewPlaceholder) {
|
||||||
<button type="button" class="product-action-btn" (click)="onQuickViewPlaceholder($event)">○</button>
|
<button type="button" class="product-action-btn" [attr.aria-label]="'catalog.quickView' | translate" (click)="onQuickViewPlaceholder($event)">○</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
.product-image {
|
.product-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 1 / 1;
|
||||||
|
min-height: 220px;
|
||||||
border: 1px solid #d3dad9;
|
border: 1px solid #d3dad9;
|
||||||
border-radius: 13px 13px 0 0;
|
border-radius: 13px 13px 0 0;
|
||||||
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
||||||
@@ -37,22 +39,22 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: white;
|
background: #fff;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover img {
|
&:hover img {
|
||||||
transform: scale(1.05);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.discount-badge {
|
.discount-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
left: 12px;
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
color: white;
|
color: #fff;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -62,7 +64,7 @@
|
|||||||
|
|
||||||
.stock-badge {
|
.stock-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
left: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
@@ -84,26 +86,26 @@
|
|||||||
.product-actions-overlay {
|
.product-actions-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
top: 44px;
|
top: 12px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-action-btn {
|
.product-action-btn {
|
||||||
width: 30px;
|
width: 36px;
|
||||||
height: 30px;
|
height: 36px;
|
||||||
border: 1px solid rgba(30, 60, 56, 0.15);
|
border: 1px solid rgba(30, 60, 56, 0.15);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
color: #1e3c38;
|
color: #1e3c38;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.18s ease, border-color 0.2s ease, color 0.2s ease;
|
transition: transform 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-action-btn:hover {
|
.product-action-btn:hover {
|
||||||
@@ -116,6 +118,13 @@
|
|||||||
color: #497671;
|
color: #497671;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-action-btn:focus-visible,
|
||||||
|
.product-link:focus-visible,
|
||||||
|
.add-to-cart-btn:focus-visible {
|
||||||
|
outline: 3px solid rgba(73, 118, 113, 0.25);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.product-action-favorite.active {
|
.product-action-favorite.active {
|
||||||
animation: favorite-pulse 280ms ease-out;
|
animation: favorite-pulse 280ms ease-out;
|
||||||
}
|
}
|
||||||
@@ -134,12 +143,13 @@
|
|||||||
|
|
||||||
.product-badges-overlay {
|
.product-badges-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px;
|
left: 12px;
|
||||||
top: 10px;
|
top: 46px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
max-width: calc(100% - 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-badge {
|
.product-badge {
|
||||||
@@ -160,13 +170,13 @@
|
|||||||
border: 1px solid #d3dad9;
|
border: 1px solid #d3dad9;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-radius: 0 0 13px 13px;
|
border-radius: 0 0 13px 13px;
|
||||||
padding: 12px 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
||||||
background: #f5f3f9;
|
background: #f5f3f9;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-name {
|
.product-name {
|
||||||
@@ -212,7 +222,7 @@
|
|||||||
.product-price {
|
.product-price {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +269,7 @@
|
|||||||
|
|
||||||
.add-to-cart-btn {
|
.add-to-cart-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
@@ -280,9 +290,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.product-image {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-action-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-details {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.product-card-compact {
|
.product-card-compact {
|
||||||
.product-details {
|
.product-details {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-description,
|
.product-description,
|
||||||
|
|||||||
@@ -6,121 +6,139 @@
|
|||||||
@for (filter of definitions; track filter.id) {
|
@for (filter of definitions; track filter.id) {
|
||||||
@if (filter.enabled) {
|
@if (filter.enabled) {
|
||||||
<section class="filter-group">
|
<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="filter-group-body" [class.collapsed]="isCollapsed(filter.id)">
|
||||||
<div class="options">
|
|
||||||
@for (option of filter.options; track option.id) {
|
@if (filter.type === 'checkbox' || filter.type === 'availability') {
|
||||||
<label class="option-check">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="number"
|
||||||
[checked]="isSelected(filter.id, option.value)"
|
[value]="state.ranges[filter.id]?.min ?? ''"
|
||||||
(change)="toggleOption(filter.id, option.value)" />
|
(input)="updateRange(filter.id, 'min', $any($event.target).value)"
|
||||||
@if (filter.type === 'availability') {
|
[placeholder]="'catalog.minValue' | translate:{ value: filter.min ?? '' }" />
|
||||||
<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>
|
</label>
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (filter.type === 'radio') {
|
<label class="range-field">
|
||||||
<div class="options">
|
<span>{{ 'catalog.to' | translate }}</span>
|
||||||
@for (option of filter.options; track option.id) {
|
|
||||||
<label class="option-check">
|
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="number"
|
||||||
[name]="'radio-' + filter.id"
|
[value]="state.ranges[filter.id]?.max ?? ''"
|
||||||
[checked]="isSelected(filter.id, option.value)"
|
(input)="updateRange(filter.id, 'max', $any($event.target).value)"
|
||||||
(change)="selectSingleOption(filter.id, option.value)" />
|
[placeholder]="'catalog.maxValue' | translate:{ value: filter.max ?? '' }" />
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@if (filter.type === 'range') {
|
@if (filter.type === 'slider') {
|
||||||
<div class="range-inputs">
|
<div class="slider-inputs">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="range"
|
||||||
[value]="state.ranges[filter.id]?.min ?? ''"
|
[min]="filter.min ?? 0"
|
||||||
(input)="updateRange(filter.id, 'min', $any($event.target).value)"
|
[max]="filter.max ?? 1000"
|
||||||
[placeholder]="'catalog.minValue' | translate:{ value: filter.min ?? '' }" />
|
[step]="filter.step ?? 1"
|
||||||
<input
|
[value]="state.ranges[filter.id]?.max ?? filter.max ?? 1000"
|
||||||
type="number"
|
(input)="updateSlider(filter.id, $any($event.target).value)" />
|
||||||
[value]="state.ranges[filter.id]?.max ?? ''"
|
<span>{{ state.ranges[filter.id]?.max ?? filter.max ?? 0 }}</span>
|
||||||
(input)="updateRange(filter.id, 'max', $any($event.target).value)"
|
</div>
|
||||||
[placeholder]="'catalog.maxValue' | translate:{ value: filter.max ?? '' }" />
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (filter.type === 'slider') {
|
@if (filter.type === 'color') {
|
||||||
<div class="slider-inputs">
|
<div class="color-options">
|
||||||
<input
|
@for (option of filter.options; track option.id) {
|
||||||
type="range"
|
<button
|
||||||
[min]="filter.min ?? 0"
|
type="button"
|
||||||
[max]="filter.max ?? 1000"
|
class="color-swatch"
|
||||||
[step]="filter.step ?? 1"
|
[class.active]="isSelected(filter.id, option.value)"
|
||||||
[value]="state.ranges[filter.id]?.max ?? filter.max ?? 1000"
|
[style.background]="option.colorHex ?? '#94a3b8'"
|
||||||
(input)="updateSlider(filter.id, $any($event.target).value)" />
|
[attr.aria-label]="option.label"
|
||||||
<span>{{ state.ranges[filter.id]?.max ?? filter.max ?? 0 }}</span>
|
(click)="toggleOption(filter.id, option.value)"></button>
|
||||||
</div>
|
}
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (filter.type === 'color') {
|
@if (filter.type === 'size') {
|
||||||
<div class="color-options">
|
<div class="size-options">
|
||||||
@for (option of filter.options; track option.id) {
|
@for (option of filter.options; track option.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-swatch"
|
class="size-chip"
|
||||||
[class.active]="isSelected(filter.id, option.value)"
|
[class.active]="isSelected(filter.id, option.value)"
|
||||||
[style.background]="option.colorHex ?? '#94a3b8'"
|
(click)="toggleOption(filter.id, option.value)">{{ option.label }}</button>
|
||||||
[attr.aria-label]="option.label"
|
}
|
||||||
(click)="toggleOption(filter.id, option.value)"></button>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (filter.type === 'size') {
|
@if (filter.type === 'rating') {
|
||||||
<div class="size-options">
|
<div class="rating-options">
|
||||||
@for (option of filter.options; track option.id) {
|
@for (option of filter.options; track option.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="size-chip"
|
class="rating-chip"
|
||||||
[class.active]="isSelected(filter.id, option.value)"
|
[class.active]="isSelected(filter.id, option.value)"
|
||||||
(click)="toggleOption(filter.id, option.value)">{{ option.label }}</button>
|
(click)="toggleOption(filter.id, option.value)">
|
||||||
}
|
<span aria-hidden="true">★</span>
|
||||||
</div>
|
<span>{{ option.label }}</span>
|
||||||
}
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (filter.type === 'rating') {
|
@if (filter.type === 'toggle') {
|
||||||
<div class="rating-options">
|
<label class="option-check">
|
||||||
@for (option of filter.options; track option.id) {
|
<input
|
||||||
<button
|
type="checkbox"
|
||||||
type="button"
|
[checked]="state.toggles[filter.id]"
|
||||||
class="rating-chip"
|
(change)="updateToggle(filter.id, $any($event.target).checked)" />
|
||||||
[class.active]="isSelected(filter.id, option.value)"
|
<span>{{ 'catalog.enabled' | translate }}</span>
|
||||||
(click)="toggleOption(filter.id, option.value)">
|
</label>
|
||||||
<span aria-hidden="true">★</span>
|
}
|
||||||
<span>{{ option.label }}</span>
|
</div>
|
||||||
</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>
|
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.catalog-filters {
|
.catalog-filters {
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,29 +19,107 @@
|
|||||||
|
|
||||||
.filter-group {
|
.filter-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
padding-top: 10px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group h4 {
|
.filter-group-toggle {
|
||||||
margin: 0;
|
min-height: 36px;
|
||||||
font-size: 0.95rem;
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
color: var(--text-primary);
|
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 {
|
.options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-check {
|
.option-check {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto auto 1fr auto;
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-check small {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
}
|
||||||
|
|
||||||
|
.option-check input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.availability-dot {
|
.availability-dot {
|
||||||
@@ -65,20 +143,23 @@
|
|||||||
|
|
||||||
.range-inputs {
|
.range-inputs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-inputs input {
|
.range-inputs input {
|
||||||
min-height: 34px;
|
min-height: 40px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
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 {
|
.slider-inputs {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-inputs input {
|
.slider-inputs input {
|
||||||
@@ -87,7 +168,8 @@
|
|||||||
|
|
||||||
.slider-inputs span {
|
.slider-inputs span {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-options,
|
.color-options,
|
||||||
@@ -99,28 +181,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-swatch {
|
.color-swatch {
|
||||||
width: 24px;
|
width: 30px;
|
||||||
height: 24px;
|
height: 30px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-swatch.active {
|
.color-swatch.active {
|
||||||
border-color: var(--text-primary);
|
border-color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.size-chip,
|
.size-chip,
|
||||||
.rating-chip {
|
.rating-chip {
|
||||||
min-height: 30px;
|
min-height: 34px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
padding: 0 10px;
|
color: var(--text-primary);
|
||||||
|
padding: 0 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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,
|
.size-chip.active,
|
||||||
@@ -129,15 +218,26 @@
|
|||||||
background: color-mix(in srgb, var(--primary-color) 8%, white);
|
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) {
|
@media (max-width: 1024px) {
|
||||||
.catalog-filters {
|
.catalog-filters {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-inputs {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-check {
|
.option-check {
|
||||||
grid-template-columns: auto auto 1fr;
|
grid-template-columns: auto auto 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||||
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
@@ -17,6 +17,19 @@ export class CatalogFiltersPanelComponent {
|
|||||||
|
|
||||||
@Output() stateChange = new EventEmitter<SearchFilterState>();
|
@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 {
|
isSelected(filterId: string, optionValue: string): boolean {
|
||||||
return (this.state.values[filterId] ?? []).includes(optionValue);
|
return (this.state.values[filterId] ?? []).includes(optionValue);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layout-btn {
|
.layout-btn {
|
||||||
min-height: 36px;
|
min-height: 40px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0 10px;
|
padding: 0 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
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 {
|
.layout-btn svg {
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
stroke-width: 1.7;
|
stroke-width: 1.7;
|
||||||
@@ -30,7 +31,8 @@
|
|||||||
|
|
||||||
.layout-btn.active {
|
.layout-btn.active {
|
||||||
border-color: var(--primary-color);
|
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 {
|
.layout-btn:hover {
|
||||||
@@ -38,6 +40,11 @@
|
|||||||
border-color: var(--primary-color);
|
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) {
|
@media (max-width: 640px) {
|
||||||
.catalog-layout-switcher {
|
.catalog-layout-switcher {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.catalog-search-results {
|
.catalog-search-results {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-head {
|
.results-head {
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-head strong {
|
.results-head strong {
|
||||||
@@ -22,13 +23,16 @@
|
|||||||
.results-skeletons {
|
.results-skeletons {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
min-height: 220px;
|
min-height: 340px;
|
||||||
border-radius: var(--radius-md);
|
border: 1px solid #d3dad9;
|
||||||
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
|
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%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.2s linear infinite;
|
animation: shimmer 1.2s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -55,11 +59,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-pager button {
|
.results-pager button {
|
||||||
min-height: 36px;
|
min-height: 40px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -68,6 +72,11 @@
|
|||||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
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) {
|
.results-pager button:hover:not(:disabled) {
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
|||||||
@@ -107,9 +107,9 @@
|
|||||||
<div class="catalog-results-panel">
|
<div class="catalog-results-panel">
|
||||||
@if (showMobileToolbar()) {
|
@if (showMobileToolbar()) {
|
||||||
<div class="catalog-mobile-toolbar" [attr.aria-label]="'catalog.mobileToolbarAria' | translate">
|
<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" [attr.aria-label]="'catalog.filtersTitle' | translate" (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" [attr.aria-label]="'catalog.sortBy' | translate" (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.gridCycle' | translate" (click)="openGridSheet()">{{ 'catalog.gridCycle' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,5 +249,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
color: #1e3c38;
|
color: #1e3c38;
|
||||||
overflow-x: clip;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-header {
|
.catalog-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 16px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-left-panel {
|
.catalog-left-panel {
|
||||||
@@ -91,25 +91,26 @@
|
|||||||
|
|
||||||
.catalog-results-panel {
|
.catalog-results-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-tools-row {
|
.catalog-tools-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-mobile-toolbar {
|
.catalog-mobile-toolbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 10px;
|
bottom: 12px;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
padding: 8px;
|
padding: 12px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: color-mix(in srgb, var(--bg-primary) 92%, white);
|
background: color-mix(in srgb, var(--bg-primary) 92%, white);
|
||||||
@@ -118,14 +119,22 @@
|
|||||||
|
|
||||||
.catalog-mobile-toolbar-btn,
|
.catalog-mobile-toolbar-btn,
|
||||||
.catalog-open-filters-btn {
|
.catalog-open-filters-btn {
|
||||||
min-height: 40px;
|
min-height: 42px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 0 10px;
|
padding: 0 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
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 {
|
.catalog-open-filters-btn {
|
||||||
@@ -135,12 +144,12 @@
|
|||||||
.catalog-sort-reset-group {
|
.catalog-sort-reset-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-reset-btn {
|
.catalog-reset-btn {
|
||||||
min-height: 36px;
|
min-height: 40px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -158,7 +167,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.catalog-save-search-btn {
|
.catalog-save-search-btn {
|
||||||
min-height: 36px;
|
min-height: 40px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -169,6 +178,17 @@
|
|||||||
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
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 {
|
.catalog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -188,6 +208,7 @@
|
|||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
box-shadow: -12px 0 28px rgba(15, 23, 42, 0.14);
|
box-shadow: -12px 0 28px rgba(15, 23, 42, 0.14);
|
||||||
|
animation: catalog-drawer-in 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-filter-drawer-head,
|
.catalog-filter-drawer-head,
|
||||||
@@ -221,19 +242,23 @@
|
|||||||
|
|
||||||
.catalog-filter-drawer-body {
|
.catalog-filter-drawer-body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
|
padding-bottom: 84px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-filter-drawer-actions {
|
.catalog-filter-drawer-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-apply-btn {
|
.catalog-apply-btn {
|
||||||
min-height: 40px;
|
min-height: 42px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
@@ -256,16 +281,17 @@
|
|||||||
box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.16);
|
box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.16);
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
animation: catalog-sheet-in 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-sort-sheet-options {
|
.catalog-sort-sheet-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-sort-option {
|
.catalog-sort-option {
|
||||||
min-height: 42px;
|
min-height: 44px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -274,6 +300,7 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-sort-option.active {
|
.catalog-sort-option.active {
|
||||||
@@ -281,6 +308,15 @@
|
|||||||
background: color-mix(in srgb, var(--primary-color) 10%, white);
|
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 {
|
.catalog-save-search-btn:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
@@ -305,11 +341,11 @@
|
|||||||
.subcategory-chips {
|
.subcategory-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subcategory-chip {
|
.subcategory-chip {
|
||||||
min-height: 32px;
|
min-height: 36px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -361,14 +397,14 @@
|
|||||||
.catalog-loading {
|
.catalog-loading {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-skeleton-card {
|
.catalog-skeleton-card {
|
||||||
min-height: 260px;
|
min-height: 340px;
|
||||||
border: 1px solid #d3dad9;
|
border: 1px solid #d3dad9;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
background: #f5f3f9;
|
background: #f5f3f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,13 +417,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.catalog-skeleton-image {
|
.catalog-skeleton-image {
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 1 / 1;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-skeleton-line {
|
.catalog-skeleton-line {
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-skeleton-title {
|
.catalog-skeleton-title {
|
||||||
@@ -442,6 +478,28 @@
|
|||||||
100% { background-position: -200% 0; }
|
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) {
|
@media (max-width: 640px) {
|
||||||
.catalog-page {
|
.catalog-page {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -454,7 +512,7 @@
|
|||||||
|
|
||||||
.catalog-products-section {
|
.catalog-products-section {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-left-panel {
|
.catalog-left-panel {
|
||||||
@@ -489,6 +547,16 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-mobile-toolbar {
|
||||||
|
bottom: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-mobile-toolbar-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-section-heading h1 {
|
.catalog-section-heading h1 {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class CatalogContainerComponent {
|
|||||||
readonly viewportWidth = signal(typeof window !== 'undefined' ? window.innerWidth : 1280);
|
readonly viewportWidth = signal(typeof window !== 'undefined' ? window.innerWidth : 1280);
|
||||||
readonly filterDrawerOpen = signal(false);
|
readonly filterDrawerOpen = signal(false);
|
||||||
readonly sortSheetOpen = signal(false);
|
readonly sortSheetOpen = 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 isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
|
readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
|
||||||
readonly isMobile = computed(() => this.viewportWidth() <= 767);
|
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 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 showDesktopFilters = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.isDrawerLayout());
|
||||||
readonly showMobileToolbar = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && this.isMobile());
|
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(() => {
|
readonly mobileSortOptions = computed(() => {
|
||||||
const allowed = new Set(['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular']);
|
const allowed = new Set(['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular']);
|
||||||
const options = this.sortDefinitions().filter(option => allowed.has(option.id));
|
const options = this.sortDefinitions().filter(option => allowed.has(option.id));
|
||||||
@@ -408,6 +409,10 @@ export class CatalogContainerComponent {
|
|||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscapeKey(): void {
|
onEscapeKey(): void {
|
||||||
|
if (this.gridSheetOpen()) {
|
||||||
|
this.closeGridSheet();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.sortSheetOpen()) {
|
if (this.sortSheetOpen()) {
|
||||||
this.closeSortSheet();
|
this.closeSortSheet();
|
||||||
}
|
}
|
||||||
@@ -471,6 +476,8 @@ export class CatalogContainerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openFilterDrawer(): void {
|
openFilterDrawer(): void {
|
||||||
|
this.closeSortSheet();
|
||||||
|
this.closeGridSheet();
|
||||||
this.filterDrawerOpen.set(true);
|
this.filterDrawerOpen.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +490,8 @@ export class CatalogContainerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openSortSheet(): void {
|
openSortSheet(): void {
|
||||||
|
this.closeFilterDrawer();
|
||||||
|
this.closeGridSheet();
|
||||||
this.sortSheetOpen.set(true);
|
this.sortSheetOpen.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +504,35 @@ export class CatalogContainerComponent {
|
|||||||
this.closeSortSheet();
|
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 {
|
browseCategories(): void {
|
||||||
this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`]);
|
this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user