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) { @if (open() && categories().length) {
<section class="dropdown-menu show p-2 category-dropdown" role="menu" aria-label="Categorías"> <section
@for (category of categories(); track category.id) { class="dropdown-menu show p-2 category-dropdown"
<div class="dropdown-submenu"> 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 <button
type="button" type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3" class="dropdown-item d-flex align-items-center justify-content-between gap-3"
[class.active]="activeCategory()?.id === category.id" [class.active]="isExpanded(category, depth)"
role="menuitem" role="menuitem"
[attr.aria-expanded]="category.subcategories.length ? activeCategory()?.id === category.id : null" [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)" (click)="selectCategory(category)"
> >
<span>{{ category.nombre }}</span> <span>{{ category.nombre }}</span>
@if (category.subcategories.length) { @if (category.subcategories.length) {
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i> <i class="fa-solid fa-chevron-right d-none d-md-inline" aria-hidden="true"></i>
} }
</button> </button>
@if (activeCategory()?.id === category.id && subcategories().length) { @if (isExpanded(category, depth) && category.subcategories.length) {
<div class="dropdown-menu show p-2 category-dropdown__submenu"> <div
@for (subcategory of subcategories(); track subcategory.id) { class="dropdown-menu show p-2 category-dropdown__submenu"
<button role="menu"
type="button" [id]="'store-category-submenu-' + category.id"
class="dropdown-item d-flex align-items-center justify-content-between gap-3" [attr.aria-label]="'Subcategorías de ' + category.nombre"
role="menuitem"
(click)="categorySelect.emit(subcategory)"
> >
<span>{{ subcategory.nombre }}</span> <ng-container
@if (subcategory.subcategories.length) { *ngTemplateOutlet="
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i> categoryTree;
} context: { $implicit: category.subcategories, depth: depth + 1 }
</button> "
} />
</div> </div>
} }
</div> </div>
} }
</section> </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'; import { Category } from '../../../../services/tenant.interface';
@Component({ @Component({
selector: 'app-category-dropdown', selector: 'app-category-dropdown',
standalone: true, standalone: true,
imports: [NgTemplateOutlet],
templateUrl: './category-dropdown.component.html', templateUrl: './category-dropdown.component.html',
styleUrl: './category-dropdown.component.scss', styleUrl: './category-dropdown.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -14,33 +16,53 @@ export class CategoryDropdownComponent {
readonly open = input(false); readonly open = input(false);
readonly categorySelect = output<Category>(); readonly categorySelect = output<Category>();
protected readonly activeCategory = signal<Category | null>(null); protected readonly expandedPath = signal<readonly number[]>([]);
protected readonly subcategories = computed(() => this.activeCategory()?.subcategories ?? []);
constructor() { constructor() {
effect(() => { effect(() => {
const categories = this.categories(); const categories = this.categories();
const activeCategory = this.activeCategory();
if (!this.open()) { if (!this.open()) {
this.activeCategory.set(null); this.expandedPath.set([]);
return; return;
} }
let nodes: readonly Category[] = categories;
if (activeCategory && !categories.some((category) => category.id === activeCategory.id)) { const validPath: number[] = [];
this.activeCategory.set(null); 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 { protected selectCategory(category: Category): void {
if (category.subcategories.length) {
this.activeCategory.update((activeCategory) =>
activeCategory?.id === category.id ? null : category,
);
return;
}
this.categorySelect.emit(category); 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 { .category-items__banner {
display: block; display: block;
width: 100%; width: 100%;

View File

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