From aa517426270a67311a79815f8d3404f516171d82 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 23 Jul 2026 16:50:12 -0300 Subject: [PATCH] feat(accordion): implement reusable accordion component with multiple item support --- .../reutilizables-test-page.component.html | 38 ++++++++ .../reutilizables-test-page.component.ts | 4 + .../accordion/accordion-item.component.html | 30 ++++++ .../accordion/accordion-item.component.scss | 55 +++++++++++ .../accordion/accordion-item.component.ts | 45 +++++++++ .../accordion/accordion.component.html | 3 + .../accordion/accordion.component.scss | 11 +++ .../accordion/accordion.component.spec.ts | 93 +++++++++++++++++++ .../accordion/accordion.component.ts | 59 ++++++++++++ 9 files changed, 338 insertions(+) create mode 100644 src/app/shared/components/accordion/accordion-item.component.html create mode 100644 src/app/shared/components/accordion/accordion-item.component.scss create mode 100644 src/app/shared/components/accordion/accordion-item.component.ts create mode 100644 src/app/shared/components/accordion/accordion.component.html create mode 100644 src/app/shared/components/accordion/accordion.component.scss create mode 100644 src/app/shared/components/accordion/accordion.component.spec.ts create mode 100644 src/app/shared/components/accordion/accordion.component.ts diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index f164026..ef75707 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -395,6 +395,44 @@
+
+

Accordion

+

+ Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene + un solo item abierto. +

+ + + + Elegí el producto, seleccioná sus variantes y agregalo al carrito. Desde allí podés + revisar el pedido y continuar al checkout. + + + + Los medios de pago dependen de la configuración de cada tienda y se muestran durante + el checkout. + + + + Este contenido no puede abrirse. + + + +

Variante múltiple y flush

+ + + + La tienda informa los métodos y costos de envío disponibles para cada pedido. + + + + Las condiciones de cambio y devolución son definidas por cada comercio. + + +
+ +
+
diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index bb0f291..2a18918 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -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', diff --git a/src/app/shared/components/accordion/accordion-item.component.html b/src/app/shared/components/accordion/accordion-item.component.html new file mode 100644 index 0000000..1c9949f --- /dev/null +++ b/src/app/shared/components/accordion/accordion-item.component.html @@ -0,0 +1,30 @@ +
+ +
+ +
+
+ +
+
diff --git a/src/app/shared/components/accordion/accordion-item.component.scss b/src/app/shared/components/accordion/accordion-item.component.scss new file mode 100644 index 0000000..98bfdab --- /dev/null +++ b/src/app/shared/components/accordion/accordion-item.component.scss @@ -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; +} diff --git a/src/app/shared/components/accordion/accordion-item.component.ts b/src/app/shared/components/accordion/accordion-item.component.ts new file mode 100644 index 0000000..73769fd --- /dev/null +++ b/src/app/shared/components/accordion/accordion-item.component.ts @@ -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(); + readonly id = input(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); + } +} diff --git a/src/app/shared/components/accordion/accordion.component.html b/src/app/shared/components/accordion/accordion.component.html new file mode 100644 index 0000000..58e4143 --- /dev/null +++ b/src/app/shared/components/accordion/accordion.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/shared/components/accordion/accordion.component.scss b/src/app/shared/components/accordion/accordion.component.scss new file mode 100644 index 0000000..c64f3a2 --- /dev/null +++ b/src/app/shared/components/accordion/accordion.component.scss @@ -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; +} diff --git a/src/app/shared/components/accordion/accordion.component.spec.ts b/src/app/shared/components/accordion/accordion.component.spec.ts new file mode 100644 index 0000000..28f6e12 --- /dev/null +++ b/src/app/shared/components/accordion/accordion.component.spec.ts @@ -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: ` + + +

Contenido uno

+
+ +

Contenido dos

+
+ + Contenido deshabilitado + +
+ `, +}) +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); + }); +}); diff --git a/src/app/shared/components/accordion/accordion.component.ts b/src/app/shared/components/accordion/accordion.component.ts new file mode 100644 index 0000000..397ee32 --- /dev/null +++ b/src/app/shared/components/accordion/accordion.component.ts @@ -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); + } +}