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 (headerClickable()) { + + } @else { +
+ +
+ } +
+ + @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} + + + + + + Accordion example + + ${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); + } + }); + } + } +}