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) {
|
@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"
|
||||||
<button
|
aria-label="Categorías"
|
||||||
type="button"
|
(pointerleave)="onPointerLeave($event)"
|
||||||
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
|
>
|
||||||
[class.active]="activeCategory()?.id === category.id"
|
<ng-container
|
||||||
role="menuitem"
|
*ngTemplateOutlet="categoryTree; context: { $implicit: categories(), depth: 0 }"
|
||||||
[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>
|
</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';
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user