diff --git a/package-lock.json b/package-lock.json
index fcb4226a..bd9917fc 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",
@@ -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"
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 00000000..58ae745e
Binary files /dev/null and b/public/accordion_example.png differ
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..6d980a95
--- /dev/null
+++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.html
@@ -0,0 +1,138 @@
+
+ @if (showIconCard()) {
+
+ }
+
+
+
+ @if (expanded()) {
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+ @if (showStartExpandAction()) {
+
+ }
+
+ @if (showDefaultTitle()) {
+
+ {{ title() | tediTranslate }}
+
+ }
+
+
+ @if (descriptionPosition() !== "end") {
+
+
+ @if (description(); as desc) {
+
+ {{ desc | tediTranslate }}
+
+ }
+ }
+
+
+
+
+
+ @if (descriptionPosition() !== "start") {
+
+
+ @if (description(); as desc) {
+
+ {{ desc | tediTranslate }}
+
+ }
+ }
+
+ @if (showEndExpandAction()) {
+
+ }
+
+
+
+
+
+ @if (headerClickable()) {
+
+
+ {{ (showExpandLabel() ? expandLabel() : "") | tediTranslate }}
+
+
+
+ } @else {
+
+ }
+
+
+
+
+
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..71643d49
--- /dev/null
+++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss
@@ -0,0 +1,175 @@
+.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);
+
+ &--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 {
+ display: flex;
+ flex: 1;
+ gap: var(--layout-grid-gutters-08);
+ align-items: center;
+ min-width: 0;
+}
+
+[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__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;
+}
+
+.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
new file mode 100644
index 00000000..4347adaf
--- /dev/null
+++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts
@@ -0,0 +1,116 @@
+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 expand when header button is clicked", () => {
+ fixture.detectChanges();
+
+ const button = fixture.debugElement.query(
+ By.css("button.tedi-accordion__header"),
+ );
+
+ expect(component.expanded()).toBe(false);
+
+ button.triggerEventHandler("click");
+ fixture.detectChanges();
+
+ expect(component.expanded()).toBe(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(
+ 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 apply selected class when selected=true", () => {
+ fixture.componentRef.setInput("selected", true);
+ fixture.detectChanges();
+
+ const item = fixture.debugElement.query(By.css(".tedi-accordion__item"));
+
+ expect(item.nativeElement.classList).toContain(
+ "tedi-accordion__item--selected",
+ );
+ });
+
+ 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");
+ });
+
+});
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..fde51df4
--- /dev/null
+++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts
@@ -0,0 +1,118 @@
+import { CommonModule } from "@angular/common";
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ViewEncapsulation,
+ input,
+ OnInit,
+ computed,
+ model,
+ inject,
+} from "@angular/core";
+import {
+ IconComponent,
+ TextComponent,
+ LinkComponent,
+ generateUUID,
+ TediTranslationPipe,
+} from "@tedi-design-system/angular/tedi";
+import { AccordionComponent } from "../accordion/accordion.component";
+
+@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 {
+ /**
+ * If false, disables header toggling and enables using interactive elements in the accordion header.
+ */
+ headerClickable = input(true);
+ /** The title of the accordion item. */
+ title = input("");
+ /**
+ * 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.
+ */
+ 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);
+ /**
+ * Controls whether the default expand/collapse icon is shown.
+ */
+ showExpandIcon = input(true);
+ /**
+ * Position of the expand action relative to the header content.
+ */
+ 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);
+ /**
+ * Marks the accordion item as selected.
+ */
+ selected = input(false);
+
+ expanded = model(false);
+
+ readonly bodyId = `tedi-accordion-body-${generateUUID()}`;
+ readonly headerId = `tedi-accordion-header-${generateUUID()}`;
+
+ private readonly accordion = inject(AccordionComponent, { optional: true });
+
+ ngOnInit() {
+ this.setExpanded(this.defaultExpanded());
+ }
+
+ toggle() {
+ this.setExpanded(!this.expanded());
+ this.accordion?.onItemToggled(this);
+ }
+
+ setExpanded(value: boolean) {
+ this.expanded.set(value);
+ }
+
+ expandLabel = computed(() =>
+ this.expanded() ? this.closeLabel() : this.openLabel(),
+ );
+
+ showStartExpandAction = computed(
+ () => this.showExpandIcon() && this.expandActionPosition() === "start",
+ );
+
+ 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
new file mode 100644
index 00000000..9b7cee62
--- /dev/null
+++ b/tedi/components/cards/accordion/accordion.stories.ts
@@ -0,0 +1,652 @@
+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,
+ ],
+ }),
+ ],
+ 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" },
+ },
+ },
+ closeLabel: {
+ control: "text",
+ description: "Label for the close action.",
+ table: {
+ category: "Accordion Item",
+ type: { summary: "string" },
+ defaultValue: { summary: "close" },
+ },
+ },
+ showExpandLabel: {
+ control: "boolean",
+ description: "Whether to show the expand/collapse labels.",
+ table: {
+ category: "Accordion Item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "true" },
+ },
+ },
+ showExpandIcon: {
+ control: "boolean",
+ 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: "true" },
+ },
+ },
+ expandActionPosition: {
+ control: "radio",
+ options: ["start", "end"],
+ description: "Position of the expand/collapse action.",
+ table: {
+ category: "Accordion Item",
+ type: { summary: "'start' | 'end'" },
+ defaultValue: { summary: "end" },
+ },
+ },
+ defaultExpanded: {
+ control: "boolean",
+ 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. If you need to have different descriptions, use slots.",
+ table: {
+ category: "Accordion Item",
+ type: { summary: "string" },
+ defaultValue: { summary: "" },
+ },
+ },
+ descriptionPosition: {
+ control: "radio",
+ 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.",
+ table: {
+ category: "Accordion Item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ selected: {
+ control: "boolean",
+ 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" },
+ },
+ },
+ },
+} 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,
+ headerClickable: true,
+ title: "Title",
+ titleLayout: "hug",
+ showDefaultTitle: true,
+ openLabel: "open",
+ closeLabel: "close",
+ showExpandLabel: true,
+ showExpandIcon: true,
+ expandActionPosition: "end",
+ defaultExpanded: false,
+ description: "",
+ descriptionPosition: "start",
+ showIconCard: false,
+ selected: false,
+ },
+ render: (args) => ({
+ props: {
+ ...args,
+ toggle(selected: boolean) {
+ this["selected"] = selected;
+ },
+ },
+ template: `
+
+
+ ${`
+ @if (!headerClickable) {
+ ${actionButtonTemplate("selected", "toggle")}
+ }
+ `}
+ Approved
+ ${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}
+
+
+
+
+
+
+
+ ${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..1797de0f
--- /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..f131dcf9
--- /dev/null
+++ b/tedi/components/cards/accordion/accordion/accordion.component.ts
@@ -0,0 +1,37 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ViewEncapsulation,
+ input,
+ contentChildren,
+} 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 {
+ /**
+ * 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);
+
+ items = contentChildren(AccordionItemComponent);
+
+ onItemToggled(activeItem: AccordionItemComponent) {
+ if (this.multiple()) return;
+
+ if (activeItem.expanded()) {
+ this.items().forEach((item) => {
+ if (item !== activeItem) {
+ item.setExpanded(false);
+ }
+ });
+ }
+ }
+}