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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user