feat(accordion): implement reusable accordion component with multiple item support

This commit is contained in:
2026-07-23 16:50:12 -03:00
parent 21119ff303
commit aa51742627
9 changed files with 338 additions and 0 deletions

View File

@@ -395,6 +395,44 @@
<hr class="section-divider" /> <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">
<div class="cart-showcase__header"> <div class="cart-showcase__header">
<div> <div>

View File

@@ -16,6 +16,8 @@ import { StepperComponent } from '../../../../shared/components/stepper/stepper.
import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component'; import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component';
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-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 { interface CarouselProductMock {
id: number; id: number;
@@ -43,6 +45,8 @@ interface CarouselProductMock {
StepComponent, StepComponent,
CarouselComponent, CarouselComponent,
MainCarouselComponent, MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
], ],
templateUrl: './reutilizables-test-page.component.html', templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss', styleUrl: './reutilizables-test-page.component.scss',

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
<div class="accordion" [class.accordion-flush]="flush()">
<ng-content />
</div>

View 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;
}

View File

@@ -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);
});
});

View 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);
}
}