feat: enhance store header layout for better responsiveness and add mobile menu functionality

This commit is contained in:
2026-08-05 12:18:50 -03:00
parent 8aea54959f
commit 21786d7b34
6 changed files with 171 additions and 82 deletions

View File

@@ -1,81 +1,83 @@
<header class="store-layout__header"> <header class="store-layout__header">
<div class="store-layout__header-main"> <div class="store-layout__header-main">
<div <div class="container-xl px-3 px-md-4 py-3 py-lg-4">
class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3" <div class="row align-items-center gy-3 gy-md-0">
> <div class="col-auto col-md-auto order-1">
<a <a
routerLink="/" routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none" class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot" data-testid="store-header-brand-slot"
aria-label="Logo del comercio" aria-label="Logo del comercio"
> >
@if (logoUrl()) { @if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" /> <img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else { } @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true"> <div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span> <span class="store-layout__brand-logo-skeleton"></span>
</div> </div>
} }
</a> </a>
<div
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
>
<div class="store-layout__search-wrapper">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div> </div>
@if (ticketsMenu(); as menu) { <div class="col-12 px-0 d-md-none order-3">
<app-button <hr class="store-layout__mobile-separator">
variant="primary" </div>
hostClass="flex-shrink-0"
data-testid="store-header-tickets" <div class="col-12 col-md order-4 order-md-2">
(click)="ticketsClick.emit()" <div class="store-layout__search-wrapper w-100 ms-md-auto me-md-3">
> <div class="input-group store-layout__search" role="search">
{{ menu.label }} <label class="visually-hidden" for="store-search-input">Buscar productos</label>
</app-button> <input
} id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
<div class="col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3">
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div
class="d-flex align-items-center justify-content-end gap-1"
aria-label="Acciones de usuario"
>
<app-cart-icon <app-cart-icon
[quantity]="cartQuantity()" [quantity]="cartQuantity()"
ariaLabel="Carrito de compras" ariaLabel="Carrito de compras"
title="Carrito" title="Carrito"
(click)="cartClick.emit()" (click)="cartClick.emit()"
/> />
<div class="store-layout__user-menu">
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button <app-icon-button
variant="user" variant="user"
ariaLabel="Mi cuenta" ariaLabel="Mi cuenta"
@@ -94,12 +96,46 @@
/> />
} }
</div> </div>
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
/>
@if (isMobileMenuOpen()) {
<div id="store-mobile-menu" class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu">
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
<hr class="my-1 border-secondary opacity-25">
@for (category of categories(); track category.id) {
<a
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onCategorySelect(category)"
>
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
</div>
}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="store-layout__categories"> <div class="store-layout__categories d-none d-md-block">
<div class="container-xl px-3 px-md-4"> <div class="container-xl px-3 px-md-4">
<div class="store-layout__category-menu"> <div class="store-layout__category-menu">
<button <button

View File

@@ -10,7 +10,6 @@
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 150px; min-width: 150px;
max-width:40px;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
@@ -42,10 +41,16 @@
} }
.store-layout__search-wrapper { .store-layout__search-wrapper {
width: min(100%, 260px); width: 100%;
min-width: 0; min-width: 0;
} }
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
}
.store-layout__search { .store-layout__search {
width: 100%; width: 100%;
} }
@@ -137,5 +142,35 @@
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 0; min-width: 0;
max-width: 150px;
}
.store-layout__tickets-btn-host {
::ng-deep .btn {
min-width: 0 !important;
padding-inline: 1rem;
}
}
.store-layout__mobile-separator {
border-color: #DDDDDD;
opacity: 1;
margin: 0 -1rem;
}
}
.store-layout__mobile-dropdown-menu {
top: 100%;
min-width: 220px;
z-index: 1050;
}
.store-layout__mobile-dropdown-item {
cursor: pointer;
color: #8a8a8a;
&:hover,
&:focus-visible {
color: #666666;
} }
} }

View File

@@ -1,5 +1,5 @@
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core'; import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces'; import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
@@ -26,6 +26,7 @@ import { CategoryDropdownComponent } from './category-dropdown/category-dropdown
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>); private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null); readonly logoUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
@@ -43,13 +44,14 @@ export class StoreHeaderComponent {
protected readonly isUserDropdownOpen = signal(false); protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false); protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3; protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false); protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true }); protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void { protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) { if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return; return;
} }
@@ -57,6 +59,7 @@ export class StoreHeaderComponent {
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
} }
@@ -64,6 +67,7 @@ export class StoreHeaderComponent {
protected onEscapeKey(): void { protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
protected toggleUserDropdown(): void { protected toggleUserDropdown(): void {
@@ -109,6 +113,25 @@ export class StoreHeaderComponent {
protected onCategorySelect(category: Category): void { protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category); this.categorySelect.emit(category);
} }
protected toggleMobileMenu(): void {
this.isMobileMenuOpen.update((isOpen) => !isOpen);
}
protected onMobileAccountClick(): void {
this.isMobileMenuOpen.set(false);
if (!this.isAuthenticated()) {
this.loginClick.emit();
} else {
const menus = this.accountNavigationMenus();
if (menus.length > 0) {
this.router.navigate([menus[0].route]);
} else {
this.router.navigate(['/cuenta']);
}
}
}
} }

View File

@@ -1,8 +1,4 @@
:host { :host {
display: inline-block;
}
:host(.product-detail__cta) {
display: block; display: block;
width: 100%; width: 100%;
} }
@@ -11,10 +7,6 @@
min-height: 40px; min-height: 40px;
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
min-width: 216px;
}
:host(.product-detail__cta) .btn {
width: 100%; width: 100%;
} }

View File

@@ -69,7 +69,8 @@
color: var(--tenant-danger); color: var(--tenant-danger);
} }
.icon-btn--user { .icon-btn--user,
.icon-btn--bars {
i { i {
width: 23px; width: 23px;
height: 23px; height: 23px;

View File

@@ -10,7 +10,8 @@ export type IconButtonVariant =
| 'angle-down' | 'angle-down'
| 'copy' | 'copy'
| 'download' | 'download'
| 'share'; | 'share'
| 'bars';
@Component({ @Component({
selector: 'app-icon-button', selector: 'app-icon-button',
@@ -38,6 +39,7 @@ export class IconButtonComponent {
copy: 'fa-solid fa-copy', copy: 'fa-solid fa-copy',
download: 'fa-solid fa-download', download: 'fa-solid fa-download',
share: 'fa-solid fa-share-nodes', share: 'fa-solid fa-share-nodes',
bars: 'fa-solid fa-bars',
}; };
return classes[this.variant()]; return classes[this.variant()];
}); });