feat(accordion): implement reusable accordion component with multiple item support
This commit is contained in:
@@ -395,6 +395,44 @@
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<div class="accordion-showcase">
|
||||
<h2>Accordion</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene
|
||||
un solo item abierto.
|
||||
</p>
|
||||
|
||||
<app-accordion>
|
||||
<app-accordion-item title="¿Cómo realizo una compra?" [open]="true">
|
||||
Elegí el producto, seleccioná sus variantes y agregalo al carrito. Desde allí podés
|
||||
revisar el pedido y continuar al checkout.
|
||||
</app-accordion-item>
|
||||
|
||||
<app-accordion-item title="¿Qué medios de pago están disponibles?">
|
||||
Los medios de pago dependen de la configuración de cada tienda y se muestran durante
|
||||
el checkout.
|
||||
</app-accordion-item>
|
||||
|
||||
<app-accordion-item title="Item deshabilitado" [disabled]="true">
|
||||
Este contenido no puede abrirse.
|
||||
</app-accordion-item>
|
||||
</app-accordion>
|
||||
|
||||
<h3 class="h5 mt-5 mb-3">Variante múltiple y flush</h3>
|
||||
|
||||
<app-accordion [allowMultiple]="true" [flush]="true">
|
||||
<app-accordion-item title="Envíos">
|
||||
La tienda informa los métodos y costos de envío disponibles para cada pedido.
|
||||
</app-accordion-item>
|
||||
|
||||
<app-accordion-item title="Cambios y devoluciones">
|
||||
Las condiciones de cambio y devolución son definidas por cada comercio.
|
||||
</app-accordion-item>
|
||||
</app-accordion>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<div class="cart-showcase">
|
||||
<div class="cart-showcase__header">
|
||||
<div>
|
||||
|
||||
@@ -16,6 +16,8 @@ import { StepperComponent } from '../../../../shared/components/stepper/stepper.
|
||||
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
||||
import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component';
|
||||
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
|
||||
import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component';
|
||||
import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component';
|
||||
|
||||
interface CarouselProductMock {
|
||||
id: number;
|
||||
@@ -43,6 +45,8 @@ interface CarouselProductMock {
|
||||
StepComponent,
|
||||
CarouselComponent,
|
||||
MainCarouselComponent,
|
||||
AccordionComponent,
|
||||
AccordionItemComponent,
|
||||
],
|
||||
templateUrl: './reutilizables-test-page.component.html',
|
||||
styleUrl: './reutilizables-test-page.component.scss',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<div
|
||||
class="accordion-header"
|
||||
role="heading"
|
||||
[attr.aria-level]="normalizedHeadingLevel()"
|
||||
>
|
||||
<button
|
||||
class="accordion-button"
|
||||
[class.collapsed]="!open()"
|
||||
type="button"
|
||||
[id]="triggerId()"
|
||||
[disabled]="disabled()"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="panelId()"
|
||||
(click)="toggle()"
|
||||
>
|
||||
{{ title() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="accordion-collapse collapse"
|
||||
[class.show]="open()"
|
||||
[id]="panelId()"
|
||||
role="region"
|
||||
[attr.aria-labelledby]="triggerId()"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<ng-content />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
:host {
|
||||
display: block;
|
||||
border: 0;
|
||||
border-radius: 0 !important;
|
||||
background: transparent;
|
||||
--bs-accordion-btn-padding-x: 0;
|
||||
--bs-accordion-btn-padding-y: 1rem;
|
||||
--bs-accordion-btn-color: #666666;
|
||||
--bs-accordion-btn-bg: transparent;
|
||||
--bs-accordion-active-color: #666666;
|
||||
--bs-accordion-active-bg: transparent;
|
||||
--bs-accordion-btn-focus-border-color: transparent;
|
||||
--bs-accordion-btn-focus-box-shadow: none;
|
||||
--bs-accordion-btn-active-icon: var(--bs-accordion-btn-icon);
|
||||
}
|
||||
|
||||
:host:not(:first-child) {
|
||||
border-top: 1px solid var(--bs-accordion-border-color);
|
||||
}
|
||||
|
||||
.accordion-button {
|
||||
border-radius: 0 !important;
|
||||
color: #666666;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
|
||||
&:not(.collapsed) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&::after,
|
||||
&:not(.collapsed)::after {
|
||||
filter: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #666666;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
padding: 0 0 1rem;
|
||||
color: #666666;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
forwardRef,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
} from '@angular/core';
|
||||
|
||||
import { AccordionComponent } from './accordion.component';
|
||||
|
||||
let nextAccordionItemId = 0;
|
||||
|
||||
@Component({
|
||||
selector: 'app-accordion-item',
|
||||
imports: [],
|
||||
templateUrl: './accordion-item.component.html',
|
||||
styleUrl: './accordion-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'accordion-item',
|
||||
},
|
||||
})
|
||||
export class AccordionItemComponent {
|
||||
private readonly accordion = inject(forwardRef(() => AccordionComponent));
|
||||
private readonly generatedId = `app-accordion-item-${nextAccordionItemId++}`;
|
||||
|
||||
readonly title = input.required<string>();
|
||||
readonly id = input<string | null>(null);
|
||||
readonly disabled = input(false);
|
||||
readonly headingLevel = input(2);
|
||||
readonly open = model(false);
|
||||
|
||||
protected readonly itemId = computed(() => this.id()?.trim() || this.generatedId);
|
||||
protected readonly triggerId = computed(() => `${this.itemId()}-trigger`);
|
||||
protected readonly panelId = computed(() => `${this.itemId()}-panel`);
|
||||
protected readonly normalizedHeadingLevel = computed(() =>
|
||||
Math.min(6, Math.max(1, Math.trunc(this.headingLevel()))),
|
||||
);
|
||||
|
||||
protected toggle(): void {
|
||||
this.accordion.toggle(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="accordion" [class.accordion-flush]="flush()">
|
||||
<ng-content />
|
||||
</div>
|
||||
11
src/app/shared/components/accordion/accordion.component.scss
Normal file
11
src/app/shared/components/accordion/accordion.component.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
--bs-accordion-border-color: #dedede;
|
||||
--bs-accordion-border-radius: 0;
|
||||
--bs-accordion-inner-border-radius: 0;
|
||||
--bs-accordion-bg: transparent;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AccordionItemComponent } from './accordion-item.component';
|
||||
import { AccordionComponent } from './accordion.component';
|
||||
|
||||
@Component({
|
||||
imports: [AccordionComponent, AccordionItemComponent],
|
||||
template: `
|
||||
<app-accordion [allowMultiple]="allowMultiple()">
|
||||
<app-accordion-item title="Primer item" [(open)]="firstOpen">
|
||||
<p id="first-content">Contenido uno</p>
|
||||
</app-accordion-item>
|
||||
<app-accordion-item title="Segundo item" [(open)]="secondOpen">
|
||||
<p id="second-content">Contenido dos</p>
|
||||
</app-accordion-item>
|
||||
<app-accordion-item title="Deshabilitado" [disabled]="true">
|
||||
Contenido deshabilitado
|
||||
</app-accordion-item>
|
||||
</app-accordion>
|
||||
`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
readonly allowMultiple = signal(false);
|
||||
readonly firstOpen = signal(false);
|
||||
readonly secondOpen = signal(false);
|
||||
}
|
||||
|
||||
describe('AccordionComponent', () => {
|
||||
async function setup() {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(TestHostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.accordion-button'),
|
||||
) as HTMLButtonElement[];
|
||||
|
||||
return { fixture, component: fixture.componentInstance, buttons };
|
||||
}
|
||||
|
||||
it('renders projected items with Bootstrap classes and accessible relationships', async () => {
|
||||
const { fixture, buttons } = await setup();
|
||||
const panels = fixture.nativeElement.querySelectorAll('.accordion-collapse');
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.accordion')).not.toBeNull();
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(buttons[0].classList.contains('collapsed')).toBe(true);
|
||||
expect(buttons[0].getAttribute('aria-controls')).toBe(panels[0].id);
|
||||
expect(panels[0].getAttribute('aria-labelledby')).toBe(buttons[0].id);
|
||||
});
|
||||
|
||||
it('opens an item and closes the previously open item by default', async () => {
|
||||
const { fixture, component, buttons } = await setup();
|
||||
|
||||
buttons[0].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.firstOpen()).toBe(true);
|
||||
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.firstOpen()).toBe(false);
|
||||
expect(component.secondOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps multiple items open when allowMultiple is enabled', async () => {
|
||||
const { fixture, component, buttons } = await setup();
|
||||
component.allowMultiple.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
buttons[0].click();
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.firstOpen()).toBe(true);
|
||||
expect(component.secondOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not open a disabled item', async () => {
|
||||
const { fixture, buttons } = await setup();
|
||||
const disabledPanel = fixture.nativeElement.querySelectorAll('.accordion-collapse')[2];
|
||||
|
||||
buttons[2].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttons[2].disabled).toBe(true);
|
||||
expect(disabledPanel.classList.contains('show')).toBe(false);
|
||||
});
|
||||
});
|
||||
59
src/app/shared/components/accordion/accordion.component.ts
Normal file
59
src/app/shared/components/accordion/accordion.component.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
contentChildren,
|
||||
effect,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
import { AccordionItemComponent } from './accordion-item.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-accordion',
|
||||
imports: [],
|
||||
templateUrl: './accordion.component.html',
|
||||
styleUrl: './accordion.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AccordionComponent {
|
||||
readonly allowMultiple = input(false);
|
||||
readonly flush = input(false);
|
||||
|
||||
private readonly items = contentChildren(AccordionItemComponent);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.items();
|
||||
|
||||
if (this.allowMultiple()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstOpenItem = items.find((item) => item.open());
|
||||
|
||||
for (const item of items) {
|
||||
if (item !== firstOpenItem && item.open()) {
|
||||
item.open.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle(itemToToggle: AccordionItemComponent): void {
|
||||
if (itemToToggle.disabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldOpen = !itemToToggle.open();
|
||||
|
||||
if (shouldOpen && !this.allowMultiple()) {
|
||||
for (const item of this.items()) {
|
||||
if (item !== itemToToggle) {
|
||||
item.open.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemToToggle.open.set(shouldOpen);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user