From e558640377a836343b63faf4336e58989bd56879 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Tue, 10 Feb 2026 12:43:15 +0200 Subject: [PATCH 1/3] feat(accordion): add accordion tedi-ready component #262 --- .../accordion-item.component.html | 133 ++++ .../accordion-item.component.scss | 158 +++++ .../accordion-item.component.spec.ts | 133 ++++ .../accordion-item.component.ts | 126 ++++ .../cards/accordion/accordion.stories.ts | 568 ++++++++++++++++++ .../accordion/accordion.component.spec.ts | 99 +++ .../accordion/accordion.component.ts | 48 ++ tedi/components/cards/accordion/index.ts | 2 + 8 files changed, 1267 insertions(+) create mode 100644 tedi/components/cards/accordion/accordion-item/accordion-item.component.html create mode 100644 tedi/components/cards/accordion/accordion-item/accordion-item.component.scss create mode 100644 tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts create mode 100644 tedi/components/cards/accordion/accordion-item/accordion-item.component.ts create mode 100644 tedi/components/cards/accordion/accordion.stories.ts create mode 100644 tedi/components/cards/accordion/accordion/accordion.component.spec.ts create mode 100644 tedi/components/cards/accordion/accordion/accordion.component.ts create mode 100644 tedi/components/cards/accordion/index.ts diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.html b/tedi/components/cards/accordion/accordion-item/accordion-item.component.html new file mode 100644 index 00000000..b7ab27ae --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.html @@ -0,0 +1,133 @@ +
+ @if (showIconCard()) { + + } + +
+ @if (withAction()) { +
+ +
+ } @else { + + } +
+ + @if (expanded()) { +
+ +
+ } +
+ + +
+ + + @if (showStartExpandIcon()) { + + } + + @if (withAction()) { + + } @else { + + {{ title() | tediTranslate }} + + } + + + + @if (descriptionPosition() !== "end") { + + + } +
+ +
+ @if (descriptionPosition() !== "start") { + + + } + + @if (withAction()) { + + } + + @if (showEndExpandIcon()) { + @if (showExpandLabel()) { + + } @else { + + } + } +
+
+ + + + + + + + + + @if (description()) { + + {{ description() ?? "" | tediTranslate }} + + } + diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss b/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss new file mode 100644 index 00000000..c75df442 --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss @@ -0,0 +1,158 @@ +.tedi-accordion__item { + display: grid; + grid-template-rows: auto auto; + grid-template-columns: auto 1fr; + align-items: stretch; + + &--selected { + border: 1px solid var(--card-border-selected); + border-radius: var(--card-radius-rounded); + + .tedi-accordion__header, + .tedi-accordion__body, + [tedi-accordion-icon-card] { + border: transparent; + } + + .tedi-accordion__header, + .tedi-accordion__body { + &--with-icon-card { + border-left: 1px solid var(--card-border-primary); + } + } + + .tedi-accordion__header { + &--expanded { + border-bottom: 1px solid var(--card-border-primary); + } + } + } +} + +.tedi-accordion__header-row { + display: flex; + grid-row: 1; + grid-column: 2; + align-items: stretch; +} + +.tedi-accordion__header { + position: relative; + display: flex; + flex: 1; + gap: var(--layout-grid-gutters-16); + align-items: center; + align-self: stretch; + padding: var(--card-padding-md-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + + &:has([tedi-accordion-action]) { + padding: var(--card-padding-sm) var(--card-padding-md-default); + } + + &--expanded { + border-radius: var(--card-radius-rounded) var(--card-radius-rounded) 0 0; + } + + &--expanded.tedi-accordion__header--with-icon-card { + border-radius: 0 var(--card-radius-rounded) 0 0; + } + + &--with-icon-card { + border-radius: var(--card-radius-sharp) var(--card-radius-rounded) + var(--card-radius-rounded) var(--card-radius-sharp); + } + + &--hoverable { + cursor: pointer; + + &:hover { + background: var(--general-surface-hover); + } + + &:active { + background: var(--general-surface-brand-tertiary); + } + + &:focus-visible { + z-index: 1; + outline: none; + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + box-shadow: + 0 0 0 1px var(--tedi-neutral-100), + 0 0 0 3px var(--tedi-primary-500); + } + } +} + +.tedi-accordion__body { + grid-row: 2; + grid-column: 2; + padding: var(--card-padding-md-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-top: none; + border-radius: 0 0 var(--card-radius-rounded) var(--card-radius-rounded); + + &--with-icon-card { + border-radius: 0 0 var(--card-radius-rounded) 0; + } +} + +.tedi-accordion__icon--expanded { + transform: rotate(180deg); +} + +.tedi-accordion__start, +.tedi-accordion__end { + display: flex; + align-items: center; +} + +.tedi-accordion__start { + flex: 1 0 0; + gap: var(--layout-grid-gutters-08); + min-width: 0; + + &--with-description, + &:has([tedi-accordion-description-start]) { + flex-direction: column; + gap: 0; + align-items: flex-start; + } +} + +.tedi-accordion__end { + gap: var(--layout-grid-gutters-16); +} + +[tedi-accordion-icon-card] { + display: inline-flex; + grid-row: 1 / span 2; + grid-column: 1; + gap: var(--layout-grid-gutters-08); + align-items: flex-start; + align-self: stretch; + justify-content: flex-end; + padding: var(--card-padding-md-default); + background: var(--card-background-secondary); + border: 1px solid var(--card-border-primary); + border-right: none; + border-radius: var(--card-radius-rounded) var(--card-radius-sharp) + var(--card-radius-sharp) var(--card-radius-rounded); +} + +.tedi-accordion__toggle-button { + display: flex; + padding: 0; + font-size: var(--body-regular-size); + background: transparent; + border: transparent; + + &-disabled { + pointer-events: none; + } +} diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts b/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts new file mode 100644 index 00000000..04039bf4 --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts @@ -0,0 +1,133 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { AccordionItemComponent } from "./accordion-item.component"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; +import { By } from "@angular/platform-browser"; + +describe("AccordionItemComponent", () => { + let fixture: ComponentFixture; + let component: AccordionItemComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AccordionItemComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }).compileComponents(); + + fixture = TestBed.createComponent(AccordionItemComponent); + component = fixture.componentInstance; + }); + + it("should create component", () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should be collapsed by default", () => { + fixture.detectChanges(); + + const body = fixture.debugElement.query(By.css(".tedi-accordion__body")); + expect(body).toBeNull(); + }); + + it("should be expanded when defaultExpanded is true", () => { + fixture.componentRef.setInput("defaultExpanded", true); + + fixture.detectChanges(); + + expect(component.expanded()).toBe(true); + + const body = fixture.debugElement.query(By.css(".tedi-accordion__body")); + expect(body).not.toBeNull(); + }); + + it("should emit toggled when header button is clicked", () => { + fixture.detectChanges(); + + const spy = jest.fn(); + component.toggled.subscribe(spy); + + const button = fixture.debugElement.query( + By.css("button.tedi-accordion__header"), + ); + + button.triggerEventHandler("click"); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalled(); + expect(component.expanded()).toBe(false); + }); + + it("should not toggle expanded when withAction is true and header is clicked", () => { + fixture.componentRef.setInput("withAction", true); + fixture.detectChanges(); + + const header = fixture.debugElement.query( + By.css(".tedi-accordion__header"), + ); + + header.triggerEventHandler("click"); + fixture.detectChanges(); + + expect(component.expanded()).toBe(false); + }); + + it("should update expanded state when setExpanded is called", () => { + component.setExpanded(true); + expect(component.expanded()).toBe(true); + + component.setExpanded(false); + expect(component.expanded()).toBe(false); + }); + + it("should set aria-expanded on header button", () => { + fixture.detectChanges(); + + const button = fixture.debugElement.query( + By.css("button.tedi-accordion__header"), + )?.nativeElement as HTMLButtonElement; + + expect(button.getAttribute("aria-expanded")).toBe("false"); + + component.setExpanded(true); + fixture.detectChanges(); + + expect(button.getAttribute("aria-expanded")).toBe("true"); + }); + + it("should emit toggled selected state when action button is clicked", () => { + const spy = jest.fn(); + component.selectToggle.subscribe(spy); + + fixture.componentRef.setInput("selected", false); + + component.onSelectClick(new MouseEvent("click")); + + expect(spy).toHaveBeenCalledWith(true); + }); + + it("should show open label when collapsed and close label when expanded", () => { + fixture.componentRef.setInput("openLabel", "Open"); + fixture.componentRef.setInput("closeLabel", "Close"); + + component.setExpanded(false); + expect(component.expandLabel()).toBe("Open"); + + component.setExpanded(true); + expect(component.expandLabel()).toBe("Close"); + }); + + it("should show start expand icon only when position=start and no action", () => { + fixture.componentRef.setInput("expandIconPosition", "start"); + fixture.componentRef.setInput("withAction", false); + + expect(component.showStartExpandIcon()).toBe(true); + expect(component.showEndExpandIcon()).toBe(false); + }); + + it("should not show expand icons when withAction is true", () => { + fixture.componentRef.setInput("expandIconPosition", "start"); + fixture.componentRef.setInput("withAction", true); + + expect(component.showStartExpandIcon()).toBe(false); + expect(component.showEndExpandIcon()).toBe(false); + }); +}); diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts b/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts new file mode 100644 index 00000000..50a3fc9b --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts @@ -0,0 +1,126 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + input, + output, + signal, + OnInit, + computed, +} from "@angular/core"; +import { + IconComponent, + TextComponent, + LinkComponent, + generateUUID, + TediTranslationPipe, +} from "@tedi-design-system/angular/tedi"; + +@Component({ + selector: "tedi-accordion-item", + standalone: true, + templateUrl: "./accordion-item.component.html", + styleUrl: "./accordion-item.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IconComponent, + CommonModule, + TextComponent, + LinkComponent, + TediTranslationPipe, + ], +}) +export class AccordionItemComponent implements OnInit { + title = input(""); + + /** Optional description text shown in the header */ + description = input(undefined); + + /** + * Whether the accordion item is expanded initially. + * Does not control the expanded state after initialization. + */ + defaultExpanded = input(false); + + /** + * Marks the accordion item as selected. + * Used together with `withAction` to render selection UI. + */ + selected = input(false); + + /** + * Controls whether the expand/collapse label is shown. + */ + showExpandLabel = input(true); + + /** + * Uses the inverted color variant for the expand label. + */ + expandLabelInverted = input(false); + + /** Label shown when accordion is collapsed */ + openLabel = input("open"); + + /** Label shown when accordion is expanded */ + closeLabel = input("close"); + + /** + * Position of the expand icon relative to the header content. + * Has no effect when `withAction` is true. + */ + expandIconPosition = input<"start" | "end">("end"); + + /** + * Position of the description relative to the title. + */ + descriptionPosition = input<"start" | "end" | "both">("start"); + + /** + * Enables the icon-card layout variant. + */ + showIconCard = input(false); + + /** + * Disables header toggling and enables action slot usage. + */ + withAction = input(false); + + expanded = signal(false); + + toggled = output(); + selectToggle = output(); + + readonly bodyId = `tedi-accordion-body-${generateUUID()}`; + readonly headerId = `tedi-accordion-header-${generateUUID()}`; + + ngOnInit() { + this.expanded.set(this.defaultExpanded()); + } + + toggle() { + this.toggled.emit(); + } + + setExpanded(value: boolean) { + this.expanded.set(value); + } + + onSelectClick(event: MouseEvent) { + event.stopPropagation(); + this.selectToggle.emit(!this.selected()); + } + + expandLabel = computed(() => + this.expanded() ? this.closeLabel() : this.openLabel(), + ); + + showStartExpandIcon = computed( + () => !this.withAction() && this.expandIconPosition() === "start", + ); + + showEndExpandIcon = computed( + () => !this.withAction() && this.expandIconPosition() === "end", + ); +} diff --git a/tedi/components/cards/accordion/accordion.stories.ts b/tedi/components/cards/accordion/accordion.stories.ts new file mode 100644 index 00000000..4f5dedb1 --- /dev/null +++ b/tedi/components/cards/accordion/accordion.stories.ts @@ -0,0 +1,568 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { AccordionComponent } from "./accordion/accordion.component"; +import { AccordionItemComponent } from "./accordion-item/accordion-item.component"; +import { IconComponent, TextComponent } from "tedi/components/base"; +import { ButtonComponent } from "tedi/components/buttons"; +import { StatusBadgeComponent } from "community/components/tags"; + +export default { + title: "TEDI-Ready/Components/Cards/Accordion", + decorators: [ + moduleMetadata({ + imports: [ + AccordionComponent, + AccordionItemComponent, + IconComponent, + TextComponent, + ButtonComponent, + StatusBadgeComponent, + ], + }), + ], + argTypes: { + multiple: { + control: "boolean", + description: "Whether multiple accordion items can be opened at once.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + title: { + control: "text", + description: "The title of the accordion item.", + table: { + type: { summary: "string" }, + defaultValue: { summary: "Title" }, + }, + }, + openLabel: { + control: "text", + description: "Label for the open action.", + table: { + type: { summary: "string" }, + defaultValue: { summary: "open" }, + }, + }, + closeLabel: { + control: "text", + description: "Label for the close action.", + table: { + type: { summary: "string" }, + defaultValue: { summary: "close" }, + }, + }, + showExpandLabel: { + control: "boolean", + description: "Whether to show the expand/collapse labels.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + expandLabelInverted: { + control: "boolean", + description: "Whether the expand label should be inverted.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + expandIconPosition: { + control: "radio", + options: ["start", "end"], + description: "Position of the expand/collapse icon.", + table: { + type: { summary: "'start' | 'end'" }, + defaultValue: { summary: "end" }, + }, + }, + defaultExpanded: { + control: "boolean", + description: + "Whether the accordion item is initially expanded or collapsed.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + description: { + control: "text", + description: "The description text of the accordion item.", + table: { + type: { summary: "string" }, + defaultValue: { summary: "" }, + }, + }, + descriptionPosition: { + control: "radio", + options: ["start", "end", "both"], + description: "Position of the description text.", + table: { + type: { summary: "'start' | 'end' | 'both'" }, + defaultValue: { summary: "start" }, + }, + }, + showIconCard: { + control: "boolean", + description: "Whether to show the icon card in the accordion item.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + withAction: { + control: "boolean", + description: + "Whether the accordion header contains an additional action element for managing selection state.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + selected: { + control: "boolean", + description: "Whether the accordion item is selected.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + }, +} as Meta; + +const contentExample = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt +ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco +laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in +voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat +non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`; + +const iconCardTemplate = ` + + + Töövõime + +`; + +const actionButtonTemplate = (selectedState: string, toggleFn: string) => ` + +`; + +export const Default: StoryObj = { + args: { + multiple: false, + title: "Title", + openLabel: "open", + closeLabel: "close", + showExpandLabel: true, + expandLabelInverted: false, + expandIconPosition: "end", + defaultExpanded: false, + description: "", + descriptionPosition: "start", + showIconCard: false, + withAction: false, + selected: false, + }, + render: (args) => ({ + props: { + ...args, + toggle(selected: boolean) { + this["selected"] = selected; + }, + }, + template: ` + + + ${` + @if (withAction) { + ${actionButtonTemplate("selected", "toggle")} + } + `} + ${iconCardTemplate} + ${contentExample} + + + ${contentExample} + + + `, + }), +}; + +export const Header: StoryObj = { + render: () => ({ + template: ` +
+ + + ${contentExample} + + + + + + Approved + ${contentExample} + + + + + + + ${contentExample} + + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + Description + + + + Another description + + + + + + + ${actionButtonTemplate("selectedA", "toggleA")} + ${contentExample} + + + + + + ${actionButtonTemplate("selectedB", "toggleB")} + ${contentExample} + + +
+ `, + props: { + selectedA: false, + selectedB: true, + toggleA(selected: boolean) { + this["selectedA"] = selected; + }, + toggleB(selected: boolean) { + this["selectedB"] = selected; + }, + }, + }), +}; + +export const HeaderWithBody: StoryObj = { + render: () => ({ + template: ` + +
+
+ + + ${contentExample} + + + + + ${contentExample} + + +
+ +
+ + + ${contentExample} + + + + + ${contentExample} + + +
+ +
+ + + ${contentExample} + ${actionButtonTemplate("selectedA", "toggleA")} + + + + + + ${contentExample} + ${actionButtonTemplate("selectedB", "toggleB")} + + +
+ +
+ + + ${contentExample} + ${actionButtonTemplate("selectedC", "toggleC")} + + + + + + ${contentExample} + ${actionButtonTemplate("selectedD", "toggleD")} + + +
+
+ `, + props: { + selectedA: false, + selectedB: false, + selectedC: true, + selectedD: true, + + toggleA(selected: boolean) { + this["selectedA"] = selected; + }, + toggleB(selected: boolean) { + this["selectedB"] = selected; + }, + toggleC(selected: boolean) { + this["selectedC"] = selected; + }, + toggleD(selected: boolean) { + this["selectedD"] = selected; + }, + }, + }), +}; + +export const AccordionWithIconCard: StoryObj = { + render: () => ({ + template: ` +
+
+ + + ${iconCardTemplate} + ${contentExample} + + + + + ${iconCardTemplate} + ${contentExample} + + +
+ +
+ + + ${iconCardTemplate} + ${contentExample} + + + + + ${iconCardTemplate} + ${contentExample} + + +
+ +
+ + + ${iconCardTemplate} + ${contentExample} + ${actionButtonTemplate("selectedA", "toggleA")} + + + + + + ${iconCardTemplate} + ${contentExample} + ${actionButtonTemplate("selectedB", "toggleB")} + + +
+ +
+ + + ${iconCardTemplate} + ${contentExample} + ${actionButtonTemplate("selectedC", "toggleC")} + + + + + + ${iconCardTemplate} + ${contentExample} + ${actionButtonTemplate("selectedD", "toggleD")} + + +
+
+ `, + props: { + selectedA: false, + selectedB: false, + selectedC: true, + selectedD: true, + + toggleA(selected: boolean) { + this["selectedA"] = selected; + }, + toggleB(selected: boolean) { + this["selectedB"] = selected; + }, + toggleC(selected: boolean) { + this["selectedC"] = selected; + }, + toggleD(selected: boolean) { + this["selectedD"] = selected; + }, + }, + }), +}; diff --git a/tedi/components/cards/accordion/accordion/accordion.component.spec.ts b/tedi/components/cards/accordion/accordion/accordion.component.spec.ts new file mode 100644 index 00000000..803cc195 --- /dev/null +++ b/tedi/components/cards/accordion/accordion/accordion.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component } from "@angular/core"; + +import { AccordionComponent } from "./accordion.component"; +import { AccordionItemComponent } from "../accordion-item/accordion-item.component"; +import { By } from "@angular/platform-browser"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; + +@Component({ + standalone: true, + imports: [AccordionComponent, AccordionItemComponent], + template: ` + + + + + + `, +}) +class TestHostComponent { + multiple = false; +} + +describe("AccordionComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let accordion: AccordionComponent; + let items: AccordionItemComponent[]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + + accordion = fixture.debugElement.query( + By.directive(AccordionComponent), + ).componentInstance; + + items = fixture.debugElement + .queryAll(By.directive(AccordionItemComponent)) + .map((de) => de.componentInstance); + }); + + it("should create accordion component", () => { + expect(accordion).toBeTruthy(); + }); + + it("should register all accordion items via ContentChildren", () => { + expect(accordion.items.length).toBe(3); + }); + + it("should expand clicked item", () => { + items[0].toggle(); + fixture.detectChanges(); + + expect(items[0].expanded()).toBe(true); + }); + + it("should collapse an expanded item when toggled again", () => { + items[0].toggle(); + fixture.detectChanges(); + expect(items[0].expanded()).toBe(true); + + items[0].toggle(); + fixture.detectChanges(); + expect(items[0].expanded()).toBe(false); + }); + + it("should collapse other items when multiple=false", () => { + items[0].toggle(); + fixture.detectChanges(); + + items[1].toggle(); + fixture.detectChanges(); + + expect(items[0].expanded()).toBe(false); + expect(items[1].expanded()).toBe(true); + }); + + it("should allow multiple items expanded when multiple=true", () => { + host.multiple = true; + fixture.detectChanges(); + + items[0].toggle(); + fixture.detectChanges(); + + items[1].toggle(); + fixture.detectChanges(); + + expect(items[0].expanded()).toBe(true); + expect(items[1].expanded()).toBe(true); + expect(items[2].expanded()).toBe(false); + }); +}); diff --git a/tedi/components/cards/accordion/accordion/accordion.component.ts b/tedi/components/cards/accordion/accordion/accordion.component.ts new file mode 100644 index 00000000..80253ee2 --- /dev/null +++ b/tedi/components/cards/accordion/accordion/accordion.component.ts @@ -0,0 +1,48 @@ +import { + ChangeDetectionStrategy, + Component, + ContentChildren, + QueryList, + ViewEncapsulation, + AfterContentInit, + input, +} from "@angular/core"; +import { AccordionItemComponent } from "../accordion-item/accordion-item.component"; + +@Component({ + selector: "tedi-accordion", + standalone: true, + template: "", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccordionComponent implements AfterContentInit { + /** + * Whether the accordion allows multiple items to be expanded at the same time. + * If false, opening one item will collapse the others automatically. + */ + multiple = input(false); + + @ContentChildren(AccordionItemComponent) + items!: QueryList; + + ngAfterContentInit() { + this.items.forEach((item) => { + item.toggled.subscribe(() => { + this.onItemToggled(item); + }); + }); + } + + private onItemToggled(activeItem: AccordionItemComponent) { + const shouldExpand = !activeItem.expanded(); + + this.items.forEach((item) => { + if (item === activeItem) { + item.setExpanded(shouldExpand); + } else if (!this.multiple()) { + item.setExpanded(false); + } + }); + } +} diff --git a/tedi/components/cards/accordion/index.ts b/tedi/components/cards/accordion/index.ts new file mode 100644 index 00000000..13b1d8cd --- /dev/null +++ b/tedi/components/cards/accordion/index.ts @@ -0,0 +1,2 @@ +export * from "./accordion-item/accordion-item.component"; +export * from "./accordion/accordion.component"; From 02e451276c5cde5f361cb8bb968832240de55069 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 13 Feb 2026 17:30:42 +0200 Subject: [PATCH 2/3] feat(accordion): update accordion tedi-ready component #262 --- package-lock.json | 2 +- package.json | 2 +- public/accordion_example.png | Bin 0 -> 10686 bytes .../accordion-item.component.html | 147 ++++++----- .../accordion-item.component.scss | 65 +++-- .../accordion-item.component.spec.ts | 43 +-- .../accordion-item.component.ts | 88 +++---- .../cards/accordion/accordion.stories.ts | 244 ++++++++++++------ .../accordion/accordion.component.spec.ts | 2 +- .../accordion/accordion.component.ts | 35 +-- tedi/components/cards/accordion/index.ts | 2 - 11 files changed, 349 insertions(+), 281 deletions(-) create mode 100644 public/accordion_example.png delete mode 100644 tedi/components/cards/accordion/index.ts diff --git a/package-lock.json b/package-lock.json index fcb4226a..64d04e78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@tedi-design-system/angular", "version": "0.0.0-semantic-version", "dependencies": { - "@tedi-design-system/core": "^3.0.1" + "@tedi-design-system/core": "^3.1.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", diff --git a/package.json b/package.json index bdd0e160..ec018260 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ngx-float-ui": "^19.0.1 || ^20.0.0" }, "dependencies": { - "@tedi-design-system/core": "^3.0.1" + "@tedi-design-system/core": "^3.1.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", diff --git a/public/accordion_example.png b/public/accordion_example.png new file mode 100644 index 0000000000000000000000000000000000000000..58ae745edd8723beb4d6f3a3fa32743c6e2c143c GIT binary patch literal 10686 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%0DdEqQEGD(6CKp##B1XNlxQwrjPrgwb$ka#Mn~A{ zxUs%Ci5>gz$9C#yp#c)~nQ?^j25fH3;;Cm}r?h3q!u&igPA+1O6X|!{c7U{rdf0cq zd*`v&j;}+!DBekGP-3WW?`y^%-EARNcN*`@edBjT1R8s2r-A`Y{2OqkN zgJVDb{rf*99cn>>z4I+Mj^Zm{ILb)%lWd!yWec@qP3#5#>LWW4VC!z9JS^Ugdh+Q- z?A{f`u3(B#NASXH^LY3DL-O6wNHmLGJA#ti*q9>n>O^$kHYMvbllvG&mH@FSllkEzbV<7 z)Oe37{&t9pgez#GoLnZJ<r_fK6Aes^i^uX9+SP^_I}?{J zh7bJ3hb5mo|I!4yGdd36dkDilP4Fg{G5PX1(u5^PM+@1w6^ZfjdF{TV{Ys@;xktR! z5{(uxO>($zpBFWzrl1qgJjaHXJzcoPiPzrVkdx}Al{~sTJeZhUWCR8`_s;F|FH3v@1-<=LAI@`DFd4z+*V7nk58?g~9>v(} z*Kq2>+eofQ@XhBhQe=pr$)aPfN>U!H{gZa``07cA$Lu}0-*+IsSwnvhE77S%oS#gf zFXYFL9cFa41u?&nVvAvBwJ~F6It>zDdf1BRUzwFJw`q)^D80NMV_7FiMxsTuGF_@{ zvu?k&3%~p2HByosF-!yTnvtQx(_Fx#FPuf4DZ8t;pA3B!OKWwUn|=)!$L8_-Up|8! z!(D8I1K4@!HcYN<;$8RLg+Gm*;)Ke8n@4x!{N*Xm$?EcDJMmX4T~vMVKG?}B8D*rU zU=@FI!{;Z`jJ_^ETc~&Ax4->8t9=C?jtOrcxd(^X{@i%)J|D!HA5 z>0C#M#B+7AAVmx+`z(xG!|y_m>!R^3ozi0JHkj6jV$9war}kn@f{Wvzh#+h zocnz8*Z9(>KZ+iY1J6^Kyklewyg`-t8pg@v%W!xsY|*N?I=zf{-QS9@{L598Nh^X> zNvt&L%+xiuatkj5zkG58>UBIz#KaBnx--}`MLHyk({!C0T kIbSU&AN=@t#)Yl^KWEr!4_fjKj{pDw07*qoM6N<$f?y4OhyVZp literal 0 HcmV?d00001 diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.html b/tedi/components/cards/accordion/accordion-item/accordion-item.component.html index b7ab27ae..6d980a95 100644 --- a/tedi/components/cards/accordion/accordion-item/accordion-item.component.html +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.html @@ -8,16 +8,7 @@ }
- @if (withAction()) { -
- -
- } @else { + @if (headerClickable()) { + } @else { +
+ +
}
@@ -47,87 +47,92 @@ -
+
+ + - @if (showStartExpandIcon()) { - - } + + + @if (showStartExpandAction()) { + + } - @if (withAction()) { - - } @else { - - {{ title() | tediTranslate }} + @if (showDefaultTitle()) { + + {{ title() | tediTranslate }} + + } - } - + @if (descriptionPosition() !== "end") { + - @if (descriptionPosition() !== "end") { - - - } + @if (description(); as desc) { + + {{ desc | tediTranslate }} + + } + } + + +
-
- @if (descriptionPosition() !== "start") { - - - } + @if (descriptionPosition() !== "start") { + - @if (withAction()) { - + @if (description(); as desc) { + + {{ desc | tediTranslate }} + } + } - @if (showEndExpandIcon()) { - @if (showExpandLabel()) { - - } @else { - - } - } -
+ @if (showEndExpandAction()) { + + } + + - + @if (headerClickable()) { +
+ + {{ (showExpandLabel() ? expandLabel() : "") | tediTranslate }} + + +
+ } @else { + + }
- - - @if (description()) { - - {{ description() ?? "" | tediTranslate }} - - } - diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss b/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss index c75df442..71643d49 100644 --- a/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss @@ -48,10 +48,6 @@ border: 1px solid var(--card-border-primary); border-radius: var(--card-radius-rounded); - &:has([tedi-accordion-action]) { - padding: var(--card-padding-sm) var(--card-padding-md-default); - } - &--expanded { border-radius: var(--card-radius-rounded) var(--card-radius-rounded) 0 0; } @@ -106,27 +102,12 @@ transform: rotate(180deg); } -.tedi-accordion__start, -.tedi-accordion__end { - display: flex; - align-items: center; -} - .tedi-accordion__start { - flex: 1 0 0; + display: flex; + flex: 1; gap: var(--layout-grid-gutters-08); + align-items: center; min-width: 0; - - &--with-description, - &:has([tedi-accordion-description-start]) { - flex-direction: column; - gap: 0; - align-items: flex-start; - } -} - -.tedi-accordion__end { - gap: var(--layout-grid-gutters-16); } [tedi-accordion-icon-card] { @@ -145,14 +126,50 @@ var(--card-radius-sharp) var(--card-radius-rounded); } +.tedi-accordion__title { + display: flex; + gap: var(--layout-grid-gutters-08); + + &--main { + display: flex; + gap: inherit; + } + + &--with-description, + &:has([tedi-accordion-start-description]) { + flex-direction: column; + gap: 0; + align-items: flex-start; + } + + &--grow { + flex: 1; + justify-content: space-between; + } +} + .tedi-accordion__toggle-button { display: flex; padding: 0; font-size: var(--body-regular-size); background: transparent; border: transparent; +} - &-disabled { - pointer-events: none; +.tedi-accordion__expand-indicator { + display: flex; + align-items: center; + color: var(--accordion-action-color); + + tedi-icon { + color: inherit; + } + + &--with-label { + tedi-icon { + margin-left: var(--button-sm-inner-spacing); + font-size: var(--tedi-size-02); + line-height: inherit; + } } } diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts b/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts index 04039bf4..4347adaf 100644 --- a/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts @@ -39,25 +39,23 @@ describe("AccordionItemComponent", () => { expect(body).not.toBeNull(); }); - it("should emit toggled when header button is clicked", () => { + it("should expand when header button is clicked", () => { fixture.detectChanges(); - const spy = jest.fn(); - component.toggled.subscribe(spy); - const button = fixture.debugElement.query( By.css("button.tedi-accordion__header"), ); + expect(component.expanded()).toBe(false); + button.triggerEventHandler("click"); fixture.detectChanges(); - expect(spy).toHaveBeenCalled(); - expect(component.expanded()).toBe(false); + expect(component.expanded()).toBe(true); }); - it("should not toggle expanded when withAction is true and header is clicked", () => { - fixture.componentRef.setInput("withAction", true); + it("should not toggle expanded when headerClickable is false and header is clicked", () => { + fixture.componentRef.setInput("headerClickable", false); fixture.detectChanges(); const header = fixture.debugElement.query( @@ -93,15 +91,15 @@ describe("AccordionItemComponent", () => { expect(button.getAttribute("aria-expanded")).toBe("true"); }); - it("should emit toggled selected state when action button is clicked", () => { - const spy = jest.fn(); - component.selectToggle.subscribe(spy); - - fixture.componentRef.setInput("selected", false); + it("should apply selected class when selected=true", () => { + fixture.componentRef.setInput("selected", true); + fixture.detectChanges(); - component.onSelectClick(new MouseEvent("click")); + const item = fixture.debugElement.query(By.css(".tedi-accordion__item")); - expect(spy).toHaveBeenCalledWith(true); + expect(item.nativeElement.classList).toContain( + "tedi-accordion__item--selected", + ); }); it("should show open label when collapsed and close label when expanded", () => { @@ -115,19 +113,4 @@ describe("AccordionItemComponent", () => { expect(component.expandLabel()).toBe("Close"); }); - it("should show start expand icon only when position=start and no action", () => { - fixture.componentRef.setInput("expandIconPosition", "start"); - fixture.componentRef.setInput("withAction", false); - - expect(component.showStartExpandIcon()).toBe(true); - expect(component.showEndExpandIcon()).toBe(false); - }); - - it("should not show expand icons when withAction is true", () => { - fixture.componentRef.setInput("expandIconPosition", "start"); - fixture.componentRef.setInput("withAction", true); - - expect(component.showStartExpandIcon()).toBe(false); - expect(component.showEndExpandIcon()).toBe(false); - }); }); diff --git a/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts b/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts index 50a3fc9b..fde51df4 100644 --- a/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts @@ -4,10 +4,10 @@ import { Component, ViewEncapsulation, input, - output, - signal, OnInit, computed, + model, + inject, } from "@angular/core"; import { IconComponent, @@ -16,6 +16,7 @@ import { generateUUID, TediTranslationPipe, } from "@tedi-design-system/angular/tedi"; +import { AccordionComponent } from "../accordion/accordion.component"; @Component({ selector: "tedi-accordion-item", @@ -33,94 +34,85 @@ import { ], }) export class AccordionItemComponent implements OnInit { - title = input(""); - - /** Optional description text shown in the header */ - description = input(undefined); - /** - * Whether the accordion item is expanded initially. - * Does not control the expanded state after initialization. + * If false, disables header toggling and enables using interactive elements in the accordion header. */ - defaultExpanded = input(false); - + headerClickable = input(true); + /** The title of the accordion item. */ + title = input(""); /** - * Marks the accordion item as selected. - * Used together with `withAction` to render selection UI. + * Sets how the accordion title stretches horizontally. + * `hug` - container sizes to its content. + * `fill` - container expands to available space, moving any trailing elements to the end. */ - selected = input(false); - + titleLayout = input<"hug" | "fill">("hug"); + /** Whether the default title text is shown in the header. */ + showDefaultTitle = input(true); + /** Label shown when accordion is collapsed */ + openLabel = input("open"); + /** Label shown when accordion is expanded */ + closeLabel = input("close"); /** * Controls whether the expand/collapse label is shown. */ showExpandLabel = input(true); - /** - * Uses the inverted color variant for the expand label. + * Controls whether the default expand/collapse icon is shown. */ - expandLabelInverted = input(false); - - /** Label shown when accordion is collapsed */ - openLabel = input("open"); - - /** Label shown when accordion is expanded */ - closeLabel = input("close"); - + showExpandIcon = input(true); /** - * Position of the expand icon relative to the header content. - * Has no effect when `withAction` is true. + * Position of the expand action relative to the header content. */ - expandIconPosition = input<"start" | "end">("end"); - + expandActionPosition = input<"start" | "end">("end"); + /** + * Whether the accordion item is expanded initially. + * Does not control the expanded state after initialization. + */ + defaultExpanded = input(false); + /** Optional description text shown in the header */ + description = input(undefined); /** * Position of the description relative to the title. */ descriptionPosition = input<"start" | "end" | "both">("start"); - /** * Enables the icon-card layout variant. */ showIconCard = input(false); - /** - * Disables header toggling and enables action slot usage. + * Marks the accordion item as selected. */ - withAction = input(false); - - expanded = signal(false); + selected = input(false); - toggled = output(); - selectToggle = output(); + expanded = model(false); readonly bodyId = `tedi-accordion-body-${generateUUID()}`; readonly headerId = `tedi-accordion-header-${generateUUID()}`; + private readonly accordion = inject(AccordionComponent, { optional: true }); + ngOnInit() { - this.expanded.set(this.defaultExpanded()); + this.setExpanded(this.defaultExpanded()); } toggle() { - this.toggled.emit(); + this.setExpanded(!this.expanded()); + this.accordion?.onItemToggled(this); } setExpanded(value: boolean) { this.expanded.set(value); } - onSelectClick(event: MouseEvent) { - event.stopPropagation(); - this.selectToggle.emit(!this.selected()); - } - expandLabel = computed(() => this.expanded() ? this.closeLabel() : this.openLabel(), ); - showStartExpandIcon = computed( - () => !this.withAction() && this.expandIconPosition() === "start", + showStartExpandAction = computed( + () => this.showExpandIcon() && this.expandActionPosition() === "start", ); - showEndExpandIcon = computed( - () => !this.withAction() && this.expandIconPosition() === "end", + showEndExpandAction = computed( + () => this.showExpandIcon() && this.expandActionPosition() === "end", ); } diff --git a/tedi/components/cards/accordion/accordion.stories.ts b/tedi/components/cards/accordion/accordion.stories.ts index 4f5dedb1..9b7cee62 100644 --- a/tedi/components/cards/accordion/accordion.stories.ts +++ b/tedi/components/cards/accordion/accordion.stories.ts @@ -19,27 +19,87 @@ export default { ], }), ], + parameters: { + docs: { + description: { + component: ` +Figma ↗
+Zeroheight ↗

+ +### Slots + +| Selector | Description | +|----------|------------| +| \`[tedi-accordion-start-action]\` | Custom actions at the start of the header. | +| \`[tedi-accordion-start-before-title]\` | Custom elements before the title. | +| \`[tedi-accordion-start-after-title]\` | Custom elements after the title. | +| \`[tedi-accordion-end-action]\` | Custom actions at the end of the header. | +| \`[tedi-accordion-start-description]\` | Custom description content rendered below the title. | +| \`[tedi-accordion-end-description]\` | Custom description content rendered at the end of the header. | +| \`[tedi-accordion-icon-card]\` | Template for rendering the icon card layout. | + `, + }, + }, + }, argTypes: { multiple: { control: "boolean", description: "Whether multiple accordion items can be opened at once.", table: { + category: "Accordion", type: { summary: "boolean" }, defaultValue: { summary: "false" }, }, }, + headerClickable: { + control: "boolean", + description: + "Defines whether the entire header acts as the toggle trigger.\n\n" + + "`true` (default): clicking anywhere on the header toggles the item.\n\n" + + "`false`: the header does not toggle automatically. You must provide a custom toggle control inside the header (e.g. button or link).", + table: { + category: "Accordion Item", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, title: { control: "text", description: "The title of the accordion item.", table: { + category: "Accordion Item", type: { summary: "string" }, defaultValue: { summary: "Title" }, }, }, + titleLayout: { + control: "radio", + options: ["hug", "fill"], + description: + "Controls how the title stretches.\n\n" + + "`hug`: wraps tightly around content.\n\n" + + "`fill`: expands to available space and pushes trailing elements to the end.", + table: { + category: "Accordion Item", + type: { summary: "'hug' | 'fill'" }, + defaultValue: { summary: "hug" }, + }, + }, + showDefaultTitle: { + control: "boolean", + description: + "Controls whether the default title text is rendered inside the header.", + table: { + category: "Accordion Item", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, openLabel: { control: "text", description: "Label for the open action.", table: { + category: "Accordion Item", type: { summary: "string" }, defaultValue: { summary: "open" }, }, @@ -48,6 +108,7 @@ export default { control: "text", description: "Label for the close action.", table: { + category: "Accordion Item", type: { summary: "string" }, defaultValue: { summary: "close" }, }, @@ -56,23 +117,27 @@ export default { control: "boolean", description: "Whether to show the expand/collapse labels.", table: { + category: "Accordion Item", type: { summary: "boolean" }, defaultValue: { summary: "true" }, }, }, - expandLabelInverted: { + showExpandIcon: { control: "boolean", - description: "Whether the expand label should be inverted.", + description: + "Whether to show the default expand/collapse icon. If false, you can add your own expand icon with slots.", table: { + category: "Accordion Item", type: { summary: "boolean" }, - defaultValue: { summary: "false" }, + defaultValue: { summary: "true" }, }, }, - expandIconPosition: { + expandActionPosition: { control: "radio", options: ["start", "end"], - description: "Position of the expand/collapse icon.", + description: "Position of the expand/collapse action.", table: { + category: "Accordion Item", type: { summary: "'start' | 'end'" }, defaultValue: { summary: "end" }, }, @@ -82,14 +147,17 @@ export default { description: "Whether the accordion item is initially expanded or collapsed.", table: { + category: "Accordion Item", type: { summary: "boolean" }, defaultValue: { summary: "false" }, }, }, description: { control: "text", - description: "The description text of the accordion item.", + description: + "The description text of the accordion item. If you need to have different descriptions, use slots.", table: { + category: "Accordion Item", type: { summary: "string" }, defaultValue: { summary: "" }, }, @@ -99,31 +167,26 @@ export default { options: ["start", "end", "both"], description: "Position of the description text.", table: { + category: "Accordion Item", type: { summary: "'start' | 'end' | 'both'" }, defaultValue: { summary: "start" }, }, }, showIconCard: { control: "boolean", - description: "Whether to show the icon card in the accordion item.", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "false" }, - }, - }, - withAction: { - control: "boolean", - description: - "Whether the accordion header contains an additional action element for managing selection state.", + description: "Whether to show the icon card.", table: { + category: "Accordion Item", type: { summary: "boolean" }, defaultValue: { summary: "false" }, }, }, selected: { control: "boolean", - description: "Whether the accordion item is selected.", + description: + "Whether the accordion item is selected. Applies a visual 'selected' state to the accordion item.", table: { + category: "Accordion Item", type: { summary: "boolean" }, defaultValue: { summary: "false" }, }, @@ -147,7 +210,7 @@ const iconCardTemplate = ` const actionButtonTemplate = (selectedState: string, toggleFn: string) => ` + ${contentExample} + +
`, props: { @@ -347,12 +423,12 @@ export const HeaderWithBody: StoryObj = {
- + ${contentExample} - + ${contentExample} @@ -360,12 +436,12 @@ export const HeaderWithBody: StoryObj = {
- + ${contentExample} - + ${contentExample} @@ -374,10 +450,11 @@ export const HeaderWithBody: StoryObj = {
${contentExample} ${actionButtonTemplate("selectedA", "toggleA")} @@ -386,11 +463,12 @@ export const HeaderWithBody: StoryObj = { ${contentExample} ${actionButtonTemplate("selectedB", "toggleB")} @@ -401,10 +479,11 @@ export const HeaderWithBody: StoryObj = {
${contentExample} ${actionButtonTemplate("selectedC", "toggleC")} @@ -413,11 +492,12 @@ export const HeaderWithBody: StoryObj = { ${contentExample} ${actionButtonTemplate("selectedD", "toggleD")} @@ -454,13 +534,13 @@ export const AccordionWithIconCard: StoryObj = {
- + ${iconCardTemplate} ${contentExample} - + ${iconCardTemplate} ${contentExample} @@ -469,13 +549,13 @@ export const AccordionWithIconCard: StoryObj = {
- + ${iconCardTemplate} ${contentExample} - + ${iconCardTemplate} ${contentExample} @@ -485,11 +565,12 @@ export const AccordionWithIconCard: StoryObj = {
${iconCardTemplate} ${contentExample} @@ -499,12 +580,13 @@ export const AccordionWithIconCard: StoryObj = { ${iconCardTemplate} ${contentExample} @@ -516,11 +598,12 @@ export const AccordionWithIconCard: StoryObj = {
${iconCardTemplate} ${contentExample} @@ -530,12 +613,13 @@ export const AccordionWithIconCard: StoryObj = { ${iconCardTemplate} ${contentExample} diff --git a/tedi/components/cards/accordion/accordion/accordion.component.spec.ts b/tedi/components/cards/accordion/accordion/accordion.component.spec.ts index 803cc195..1797de0f 100644 --- a/tedi/components/cards/accordion/accordion/accordion.component.spec.ts +++ b/tedi/components/cards/accordion/accordion/accordion.component.spec.ts @@ -51,7 +51,7 @@ describe("AccordionComponent", () => { }); it("should register all accordion items via ContentChildren", () => { - expect(accordion.items.length).toBe(3); + expect(accordion.items().length).toBe(3); }); it("should expand clicked item", () => { diff --git a/tedi/components/cards/accordion/accordion/accordion.component.ts b/tedi/components/cards/accordion/accordion/accordion.component.ts index 80253ee2..f131dcf9 100644 --- a/tedi/components/cards/accordion/accordion/accordion.component.ts +++ b/tedi/components/cards/accordion/accordion/accordion.component.ts @@ -1,11 +1,9 @@ import { ChangeDetectionStrategy, Component, - ContentChildren, - QueryList, ViewEncapsulation, - AfterContentInit, input, + contentChildren, } from "@angular/core"; import { AccordionItemComponent } from "../accordion-item/accordion-item.component"; @@ -16,33 +14,24 @@ import { AccordionItemComponent } from "../accordion-item/accordion-item.compone encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccordionComponent implements AfterContentInit { +export class AccordionComponent { /** * Whether the accordion allows multiple items to be expanded at the same time. * If false, opening one item will collapse the others automatically. */ multiple = input(false); - @ContentChildren(AccordionItemComponent) - items!: QueryList; + items = contentChildren(AccordionItemComponent); - ngAfterContentInit() { - this.items.forEach((item) => { - item.toggled.subscribe(() => { - this.onItemToggled(item); - }); - }); - } + onItemToggled(activeItem: AccordionItemComponent) { + if (this.multiple()) return; - private onItemToggled(activeItem: AccordionItemComponent) { - const shouldExpand = !activeItem.expanded(); - - this.items.forEach((item) => { - if (item === activeItem) { - item.setExpanded(shouldExpand); - } else if (!this.multiple()) { - item.setExpanded(false); - } - }); + if (activeItem.expanded()) { + this.items().forEach((item) => { + if (item !== activeItem) { + item.setExpanded(false); + } + }); + } } } diff --git a/tedi/components/cards/accordion/index.ts b/tedi/components/cards/accordion/index.ts deleted file mode 100644 index 13b1d8cd..00000000 --- a/tedi/components/cards/accordion/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./accordion-item/accordion-item.component"; -export * from "./accordion/accordion.component"; From be65177aa9aa5fd0a6da7b2d9e13006b40eb06b1 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Mon, 16 Feb 2026 10:02:57 +0200 Subject: [PATCH 3/3] feat(accordion): update package-lock.json #262 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 64d04e78..bd9917fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9631,9 +9631,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-3.0.1.tgz", - "integrity": "sha512-ioet8RlFmWjg8fic4WUuYeavLiqUsKx3vFGZzzXkL91xNNjHexNVKhhtMLLkpCywzOc2tKXMx3AYdDhu2dsbwg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-3.1.0.tgz", + "integrity": "sha512-hI59htF7iEZpba21p/cnPx9kt9Uud3WQ2aUw0+b9+/bvHk5OwcoLPwn9UkyZgeQfGpz8uHMJec3ugEVdrQFZ2A==", "engines": { "node": ">=18.0.0", "npm": ">=8.0.0"