feat(category-dropdown): refactor dropdown to use ngTemplateOutlet for improved rendering and add hover functionality

feat(category-items-page): update styles and encapsulation for better layout handling
This commit is contained in:
2026-10-01 15:08:04 -03:00
parent 96e51b060e
commit 663d65a480
5 changed files with 193 additions and 52 deletions

View File

@@ -1,39 +1,52 @@
@if (open() && categories().length) {
<section class="dropdown-menu show p-2 category-dropdown" role="menu" aria-label="Categorías">
@for (category of categories(); track category.id) {
<div class="dropdown-submenu">
<button
type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
[class.active]="activeCategory()?.id === category.id"
role="menuitem"
[attr.aria-expanded]="category.subcategories.length ? activeCategory()?.id === category.id : null"
(click)="selectCategory(category)"
>
<span>{{ category.nombre }}</span>
@if (category.subcategories.length) {
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
}
</button>
@if (activeCategory()?.id === category.id && subcategories().length) {
<div class="dropdown-menu show p-2 category-dropdown__submenu">
@for (subcategory of subcategories(); track subcategory.id) {
<button
type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
role="menuitem"
(click)="categorySelect.emit(subcategory)"
>
<span>{{ subcategory.nombre }}</span>
@if (subcategory.subcategories.length) {
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
}
</button>
}
</div>
}
</div>
}
<section
class="dropdown-menu show p-2 category-dropdown"
role="menu"
aria-label="Categorías"
(pointerleave)="onPointerLeave($event)"
>
<ng-container
*ngTemplateOutlet="categoryTree; context: { $implicit: categories(), depth: 0 }"
/>
</section>
}
<ng-template #categoryTree let-nodes let-depth="depth">
@for (category of nodes; track category.id) {
<div class="dropdown-submenu" (pointerenter)="onPointerEnter($event, category, depth)">
<button
type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
[class.active]="isExpanded(category, depth)"
role="menuitem"
[attr.aria-expanded]="category.subcategories.length ? isExpanded(category, depth) : null"
[attr.aria-controls]="
category.subcategories.length ? 'store-category-submenu-' + category.id : null
"
(focus)="onFocus(category, depth)"
(click)="selectCategory(category)"
>
<span>{{ category.nombre }}</span>
@if (category.subcategories.length) {
<i class="fa-solid fa-chevron-right d-none d-md-inline" aria-hidden="true"></i>
}
</button>
@if (isExpanded(category, depth) && category.subcategories.length) {
<div
class="dropdown-menu show p-2 category-dropdown__submenu"
role="menu"
[id]="'store-category-submenu-' + category.id"
[attr.aria-label]="'Subcategorías de ' + category.nombre"
>
<ng-container
*ngTemplateOutlet="
categoryTree;
context: { $implicit: category.subcategories, depth: depth + 1 }
"
/>
</div>
}
</div>
}
</ng-template>

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CategoryDropdownComponent } from './category-dropdown.component';
import { Category } from '../../../../services/tenant.interface';
describe('CategoryDropdownComponent', () => {
const parent: Category = {
id: 1,
nombre: 'Indumentaria',
subcategories: [
{
id: 2,
nombre: 'Buzos',
subcategories: [{ id: 3, nombre: 'Con capucha', subcategories: [] }],
},
],
};
const leaf: Category = { id: 4, nombre: 'Accesorios', subcategories: [] };
beforeEach(() => TestBed.configureTestingModule({ imports: [CategoryDropdownComponent] }));
function create() {
const fixture = TestBed.createComponent(CategoryDropdownComponent);
fixture.componentRef.setInput('categories', [parent, leaf]);
fixture.componentRef.setInput('open', true);
fixture.detectChanges();
return fixture;
}
function pointer(element: Element, type: string) {
const event = new Event(type);
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
element.dispatchEvent(event);
}
it('opens subcategories on hover and navigates to the parent on click', () => {
const fixture = create();
const element = fixture.nativeElement as HTMLElement;
const select = vi.fn();
fixture.componentInstance.categorySelect.subscribe(select);
expect(element.querySelector('.category-dropdown__submenu')).toBeNull();
pointer(element.querySelector('.dropdown-submenu')!, 'pointerenter');
fixture.detectChanges();
expect(element.querySelector('.category-dropdown__submenu')?.textContent).toContain('Buzos');
expect(select).not.toHaveBeenCalled();
element.querySelector<HTMLButtonElement>('.dropdown-item')!.click();
expect(select).toHaveBeenCalledWith(parent);
});
it('supports nested hover menus and closes them when leaving the dropdown', () => {
const fixture = create();
const element = fixture.nativeElement as HTMLElement;
pointer(element.querySelector('.dropdown-submenu')!, 'pointerenter');
fixture.detectChanges();
pointer(
element.querySelector('.category-dropdown__submenu .dropdown-submenu')!,
'pointerenter',
);
fixture.detectChanges();
expect(element.textContent).toContain('Con capucha');
pointer(element.querySelector('.category-dropdown')!, 'pointerleave');
fixture.detectChanges();
expect(element.querySelector('.category-dropdown__submenu')).toBeNull();
});
it('opens subcategories with keyboard focus and navigates leaf categories', () => {
const fixture = create();
const element = fixture.nativeElement as HTMLElement;
element.querySelector('.dropdown-item')!.dispatchEvent(new Event('focus'));
fixture.detectChanges();
expect(element.querySelector('.category-dropdown__submenu')).not.toBeNull();
const select = vi.fn();
fixture.componentInstance.categorySelect.subscribe(select);
const buttons = Array.from(element.querySelectorAll<HTMLButtonElement>('.dropdown-item'));
buttons.find((button) => button.textContent?.trim() === 'Accesorios')!.click();
expect(select).toHaveBeenCalledWith(leaf);
});
it('clears expanded menus when closed or when their category is removed', () => {
const fixture = create();
const element = fixture.nativeElement as HTMLElement;
pointer(element.querySelector('.dropdown-submenu')!, 'pointerenter');
fixture.detectChanges();
fixture.componentRef.setInput('open', false);
fixture.detectChanges();
fixture.componentRef.setInput('open', true);
fixture.detectChanges();
expect(element.querySelector('.category-dropdown__submenu')).toBeNull();
pointer(element.querySelector('.dropdown-submenu')!, 'pointerenter');
fixture.detectChanges();
fixture.componentRef.setInput('categories', [leaf]);
fixture.detectChanges();
expect(element.querySelector('.category-dropdown__submenu')).toBeNull();
});
});

View File

@@ -1,10 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ChangeDetectionStrategy, Component, effect, input, output, signal } from '@angular/core';
import { Category } from '../../../../services/tenant.interface';
@Component({
selector: 'app-category-dropdown',
standalone: true,
imports: [NgTemplateOutlet],
templateUrl: './category-dropdown.component.html',
styleUrl: './category-dropdown.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -14,33 +16,53 @@ export class CategoryDropdownComponent {
readonly open = input(false);
readonly categorySelect = output<Category>();
protected readonly activeCategory = signal<Category | null>(null);
protected readonly subcategories = computed(() => this.activeCategory()?.subcategories ?? []);
protected readonly expandedPath = signal<readonly number[]>([]);
constructor() {
effect(() => {
const categories = this.categories();
const activeCategory = this.activeCategory();
if (!this.open()) {
this.activeCategory.set(null);
this.expandedPath.set([]);
return;
}
if (activeCategory && !categories.some((category) => category.id === activeCategory.id)) {
this.activeCategory.set(null);
let nodes: readonly Category[] = categories;
const validPath: number[] = [];
for (const id of this.expandedPath()) {
const category = nodes.find((node) => node.id === id);
if (!category?.subcategories.length) break;
validPath.push(id);
nodes = category.subcategories;
}
if (validPath.length !== this.expandedPath().length) {
this.expandedPath.set(validPath);
}
});
}
protected selectCategory(category: Category): void {
if (category.subcategories.length) {
this.activeCategory.update((activeCategory) =>
activeCategory?.id === category.id ? null : category,
);
return;
}
this.categorySelect.emit(category);
}
protected isExpanded(category: Category, depth: number): boolean {
return this.expandedPath()[depth] === category.id;
}
protected expand(category: Category, depth: number): void {
this.expandedPath.update((path) => [
...path.slice(0, depth),
...(category.subcategories.length ? [category.id] : []),
]);
}
protected onPointerEnter(event: PointerEvent, category: Category, depth: number): void {
if (event.pointerType === 'mouse') this.expand(category, depth);
}
protected onPointerLeave(event: PointerEvent): void {
if (event.pointerType === 'mouse') this.expandedPath.set([]);
}
protected onFocus(category: Category, depth: number): void {
this.expand(category, depth);
}
}

View File

@@ -1,3 +1,12 @@
app-category-items-page {
display: block;
}
// Override the layout's Bootstrap padding only while this page is mounted.
app-store-layout main > section.py-5:has(app-category-items-page) {
padding-top: 0 !important;
}
.category-items__banner {
display: block;
width: 100%;

View File

@@ -4,6 +4,7 @@ import {
Component,
DestroyRef,
Injector,
ViewEncapsulation,
inject,
signal,
} from '@angular/core';
@@ -51,6 +52,7 @@ interface CategoryRouteState {
imports: [ProductListComponent],
templateUrl: './category-items-page.component.html',
styleUrl: './category-items-page.component.scss',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoryItemsPageComponent {