diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 95a595e1d..242ee3c0b 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -78,6 +78,7 @@ const preview: Preview = { + @@ -106,16 +107,16 @@ const preview: Preview = { description: "TEDI-ready", }, partiallyTediReady: { - background: '#9bbb5f', - color: '#fff', + background: "#9bbb5f", + color: "#fff", description: - 'This component lacks some TEDI-Ready functionality, e.g it may rely on another component that has not yet been developed', + "This component lacks some TEDI-Ready functionality, e.g it may rely on another component that has not yet been developed", }, mobileViewDifference: { - background: '#99BDDA', - color: '#000', + background: "#99BDDA", + color: "#000", description: - 'This component has a different layout on mobile. Use the mobile breakpoint or resize the browser window to review the mobile design.', + "This component has a different layout on mobile. Use the mobile breakpoint or resize the browser window to review the mobile design.", }, }, }, diff --git a/community/components/cards/accordion/accordion.stories.ts b/community/components/cards/accordion/accordion.stories.ts index 9601a1e91..768b68889 100644 --- a/community/components/cards/accordion/accordion.stories.ts +++ b/community/components/cards/accordion/accordion.stories.ts @@ -39,6 +39,11 @@ export default { ], }), ], + parameters: { + status: { + type: ["deprecated", "existsInTediReady"], + }, + }, } as Meta; type AccordionStory = StoryObj; diff --git a/community/components/cards/accordion/accordion/accordion.component.ts b/community/components/cards/accordion/accordion/accordion.component.ts index 37370b6b0..4021f9f0f 100644 --- a/community/components/cards/accordion/accordion/accordion.component.ts +++ b/community/components/cards/accordion/accordion/accordion.component.ts @@ -8,6 +8,9 @@ import { } from "@angular/core"; import { AccordionItemComponent } from "../accordion-item/accordion-item.component"; +/** + * @deprecated Use Accordion from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ selector: "tedi-accordion", standalone: true, diff --git a/package-lock.json b/package-lock.json index 8344aa882..fa0c85efb 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", @@ -9904,9 +9904,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 0a5928551..470804305 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/custom_accordion_1.png b/public/custom_accordion_1.png new file mode 100644 index 000000000..2930d0168 Binary files /dev/null and b/public/custom_accordion_1.png differ diff --git a/public/custom_accordion_2.png b/public/custom_accordion_2.png new file mode 100644 index 000000000..58ae745ed Binary files /dev/null and b/public/custom_accordion_2.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 000000000..03c309e9e --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.html @@ -0,0 +1,135 @@ +
+ @if (showIconCard()) { + + } + +
+ @if (headerClickable()) { + + } @else { +
+ +
+ } +
+ +
+
+ +
+
+
+ + +
+ + + + + + + @if (showStartExpandAction()) { + + } + + @if (showSeparateTitle()) { + + {{ 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 000000000..eaa603256 --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.scss @@ -0,0 +1,194 @@ +.tedi-accordion__item { + display: grid; + grid-template-rows: max-content minmax(0, 0fr); + grid-template-columns: auto 1fr; + transition: grid-template-rows 0.3s ease; + + &--selected { + border: 1px solid var(--card-border-selected); + border-radius: var(--card-radius-rounded); + + .tedi-accordion__header, + &.tedi-accordion__item--expanded .tedi-accordion__body, + [tedi-accordion-icon-card] { + border: transparent; + } + + .tedi-accordion__header, + &.tedi-accordion__item--expanded .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); + } + } + } + + &--expanded { + grid-template-rows: max-content minmax(0, 1fr); + + .tedi-accordion__body { + 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__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; + 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; + overflow: hidden; + background: var(--card-background-primary); + + &--inner { + padding: var(--card-padding-md-default); + } +} + +.tedi-accordion__icon { + transition: transform 0.2s ease-in-out; + + &--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; + align-items: center; + } + + &--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; + } + } +} + +.tedi-link tedi-icon.tedi-accordion__icon--no-label { + font-size: var(--icon-05); +} 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 000000000..b5d7e3e61 --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.spec.ts @@ -0,0 +1,135 @@ +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: "en" }], + }).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.nativeElement.offsetHeight).toBe(0); + }); + + 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"); + }); + + it("should include custom header and body classes when set", () => { + fixture.componentRef.setInput("headerClass", "custom-header"); + fixture.componentRef.setInput("bodyClass", "custom-body"); + fixture.detectChanges(); + + expect(component.headerClasses()).toEqual({ + "custom-header": true, + "tedi-accordion__header": true, + "tedi-accordion__header--hoverable": true, + "tedi-accordion__header--expanded": false, + "tedi-accordion__header--with-icon-card": false, + }); + + expect(component.bodyClasses()).toEqual({ + "custom-body": true, + "tedi-accordion__body": true, + "tedi-accordion__body--with-icon-card": 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 000000000..9460d9f12 --- /dev/null +++ b/tedi/components/cards/accordion/accordion-item/accordion-item.component.ts @@ -0,0 +1,152 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + input, + OnInit, + computed, + model, + inject, +} from "@angular/core"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { TextComponent } from "../../../base/text/text.component"; +import { LinkComponent } from "../../../navigation/link/link.component"; +import { TediTranslationPipe } from "../../../../services/translation/translation.pipe"; +import { AccordionComponent } from "../accordion/accordion.component"; +import { NgClass } from "@angular/common"; +import { _IdGenerator } from "@angular/cdk/a11y"; + +@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, + NgClass, + ], +}) +export class AccordionItemComponent implements OnInit { + readonly idGenerator = inject(_IdGenerator); + readonly bodyId = this.idGenerator.getId("tedi-accordion-body"); + readonly headerId = this.idGenerator.getId("tedi-accordion-header"); + /** + * 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 title is rendered as separate text in the accordion header. + * If false and `showExpandLabel` is true, the title is used as the expand button label. + */ + showSeparateTitle = 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 action is shown. + */ + showDefaultExpandAction = 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); + /** + * Custom CSS classes for the accordion header. + */ + headerClass = input(null); + /** + * Custom CSS classes for the accordion body. + */ + bodyClass = input(null); + + expanded = model(false); + + 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.showDefaultExpandAction() && this.expandActionPosition() === "start", + ); + + showEndExpandAction = computed( + () => + this.showDefaultExpandAction() && this.expandActionPosition() === "end", + ); + + readonly headerClasses = computed(() => { + const customClass = this.headerClass(); + + return { + "tedi-accordion__header": true, + ...(customClass ? { [customClass]: true } : {}), + "tedi-accordion__header--hoverable": this.headerClickable(), + "tedi-accordion__header--expanded": this.expanded(), + "tedi-accordion__header--with-icon-card": this.showIconCard(), + }; + }); + + readonly bodyClasses = computed(() => { + const customClass = this.bodyClass(); + return { + "tedi-accordion__body": true, + ...(customClass ? { [customClass]: true } : {}), + "tedi-accordion__body--with-icon-card": this.showIconCard(), + }; + }); +} diff --git a/tedi/components/cards/accordion/accordion.stories.ts b/tedi/components/cards/accordion/accordion.stories.ts new file mode 100644 index 000000000..27c1cd8c1 --- /dev/null +++ b/tedi/components/cards/accordion/accordion.stories.ts @@ -0,0 +1,872 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { + AccordionComponent, + AccordionItemComponent, + IconComponent, + TextComponent, + ButtonComponent, + StatusBadgeComponent, + CheckboxComponent, +} from "@tedi-design-system/angular/tedi"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +document.cookie = "tedi-lang=en; path=/;"; + +/** + * Figma ↗
+ * Zeroheight ↗

+ */ + +export default { + title: "TEDI-Ready/Components/Cards/Accordion", + decorators: [ + moduleMetadata({ + imports: [ + AccordionComponent, + AccordionItemComponent, + IconComponent, + TextComponent, + ButtonComponent, + StatusBadgeComponent, + CheckboxComponent, + ], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "en" }], + }), + ], + argTypes: { + allowMultiple: { + 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" }, + }, + }, + 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" }, + }, + }, + showSeparateTitle: { + control: "boolean", + description: + "Controls whether the title is rendered as a separate text in the accordion header.\n" + + "If false and `showExpandLabel` is true, the title is used as the expand button label.", + 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" }, + }, + }, + showDefaultExpandAction: { + 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" }, + }, + }, + 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" }, + }, + }, + headerClass: { + control: "text", + description: "Custom CSS classes for the accordion header.", + table: { + category: "Accordion Item", + type: { summary: "string" }, + }, + }, + bodyClass: { + control: "text", + description: "Custom CSS classes for the accordion body.", + table: { + category: "Accordion Item", + type: { summary: "string" }, + }, + }, + }, +} 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 = { + parameters: { + docs: { + description: { + story: ` +| 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. | + `, + }, + }, + }, + args: { + allowMultiple: false, + headerClickable: true, + title: "Title", + titleLayout: "hug", + showSeparateTitle: true, + openLabel: "open", + closeLabel: "close", + showExpandLabel: true, + showDefaultExpandAction: true, + expandActionPosition: "end", + defaultExpanded: false, + descriptionPosition: "start", + showIconCard: false, + selected: false, + }, + render: (args) => ({ + props: { + ...args, + toggle(selected: boolean) { + this["selected"] = selected; + }, + }, + template: ` + + + ${` + @if (!headerClickable) { + ${actionButtonTemplate("selected", "toggle")} + } + `} + + ${iconCardTemplate} + ${contentExample} + + + ${contentExample} + + + `, + }), +}; + +export const Variants: StoryObj = { + render: () => ({ + template: ` +
+ + + ${contentExample} + + + + + + + ${contentExample} + + + + + + + ${contentExample} + + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + ${contentExample} + + + + + + + Description + + + Another description + + ${contentExample} + + + + + + ${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 ActionTypes: StoryObj = { + render: () => ({ + template: ` + +
+
+ + + ${contentExample} + + + + + ${contentExample} + + +
+ +
+ + + ${contentExample} + + + + + ${contentExample} + + +
+ +
+ + + ${contentExample} + + + + + ${contentExample} + + +
+ +
+ + + ${contentExample} + + + + + ${contentExample} + + +
+ +
+ + + ${actionButtonTemplate("selectedA", "toggleA")} + ${contentExample} + + + + + + ${actionButtonTemplate("selectedB", "toggleB")} + ${contentExample} + + +
+ +
+ + + ${actionButtonTemplate("selectedC", "toggleC")} + ${contentExample} + + + + + + ${actionButtonTemplate("selectedD", "toggleD")} + ${contentExample} + + +
+
+ `, + 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; + }, + }, + }), + parameters: { + a11y: { + config: { + rules: [{ id: "landmark-unique", enabled: false }], + }, + }, + }, +}; + +export const WithIconCard: StoryObj = { + render: () => ({ + template: ` +
+
+ + + ${iconCardTemplate} + ${contentExample} + + + + + ${iconCardTemplate} + ${contentExample} + + +
+ +
+ + + ${iconCardTemplate} + ${contentExample} + + + + + ${iconCardTemplate} + ${contentExample} + + +
+ +
+ + + ${iconCardTemplate} + ${actionButtonTemplate("selectedA", "toggleA")} + ${contentExample} + + + + + + ${iconCardTemplate} + ${actionButtonTemplate("selectedB", "toggleB")} + ${contentExample} + + +
+ +
+ + + ${iconCardTemplate} + ${actionButtonTemplate("selectedC", "toggleC")} + ${contentExample} + + + + + + ${iconCardTemplate} + ${actionButtonTemplate("selectedD", "toggleD")} + ${contentExample} + + +
+
+ `, + 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 Customized: StoryObj = { + render: () => ({ + props: { + selectedState: false, + toggleSelect(event: Event) { + const checkbox = event.target as HTMLInputElement; + this["selectedState"] = checkbox.checked; + }, + }, + template: ` + +
+ + + + ${contentExample} + + + + + + + + ${contentExample} + + + + + + + + ${contentExample} + + + + + + + ${contentExample} + + + + + + Accordion example + + mari.maasikas@gmail.com + + + Verified + + ${contentExample} + + + + + + Accordion example + + 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. + + ${contentExample} + + + + + + Accordion example + + 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. + + + ${contentExample} + + +
+ `, + }), +}; + +export const AccordionBehavior: StoryObj = { + render: () => ({ + template: ` +
+

Single-expand accordion

+ + + ${contentExample} + + + ${contentExample} + + + +

Multi-expand accordion

+ + + ${contentExample} + + + ${contentExample} + + +
+ `, + }), +}; diff --git a/tedi/components/cards/accordion/accordion/accordion.component.scss b/tedi/components/cards/accordion/accordion/accordion.component.scss new file mode 100644 index 000000000..c98fcb9b1 --- /dev/null +++ b/tedi/components/cards/accordion/accordion/accordion.component.scss @@ -0,0 +1,5 @@ +tedi-accordion { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-08); +} 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 000000000..7311ea516 --- /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 { + allowMultiple = 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: "en" }], + }).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 allowMultiple=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 allowMultiple=true", () => { + host.allowMultiple = 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 000000000..9d5f3c357 --- /dev/null +++ b/tedi/components/cards/accordion/accordion/accordion.component.ts @@ -0,0 +1,38 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + input, + contentChildren, +} from "@angular/core"; +import { AccordionItemComponent } from "../accordion-item/accordion-item.component"; + +@Component({ + selector: "tedi-accordion", + standalone: true, + template: "", + styleUrl: "./accordion.component.scss", + 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. + */ + allowMultiple = input(false); + + items = contentChildren(AccordionItemComponent); + + onItemToggled(activeItem: AccordionItemComponent) { + if (this.allowMultiple()) return; + + 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 new file mode 100644 index 000000000..d4c8cd77d --- /dev/null +++ b/tedi/components/cards/accordion/index.ts @@ -0,0 +1,2 @@ +export * from "./accordion/accordion.component"; +export * from "./accordion-item/accordion-item.component"; diff --git a/tedi/components/cards/index.ts b/tedi/components/cards/index.ts new file mode 100644 index 000000000..d236f3b99 --- /dev/null +++ b/tedi/components/cards/index.ts @@ -0,0 +1 @@ +export * from "./accordion"; diff --git a/tedi/components/index.ts b/tedi/components/index.ts index b92d21eb8..023e4e11a 100644 --- a/tedi/components/index.ts +++ b/tedi/components/index.ts @@ -9,3 +9,4 @@ export * from "./navigation"; export * from "./overlay"; export * from "./notifications"; export * from "./tags"; +export * from "./cards"; diff --git a/tedi/components/notifications/toast/toast.component.scss b/tedi/components/notifications/toast/toast.component.scss index 34e6bff1a..8581b34bd 100644 --- a/tedi/components/notifications/toast/toast.component.scss +++ b/tedi/components/notifications/toast/toast.component.scss @@ -11,7 +11,7 @@ tedi-toast { padding: var(--toast-outer-spacing, 4px); tedi-alert { - box-shadow: 0 4px 10px 0 var(--tedi-alpha-14, rgba(0, 0, 0, 0.14)); + box-shadow: 0 4px 10px 0 var(--tedi-alpha-14, rgb(0 0 0 / 14%)); } } @@ -22,9 +22,9 @@ tedi-toast { width: calc(100% - var(--toast-outer-spacing) * 2); height: 4px; background: var(--toast-progress-color); + border-radius: 0 var(--alert-radius) 0 var(--alert-radius); transform-origin: left; animation: toast-progress linear forwards; - border-radius: 0 var(--alert-radius) 0 var(--alert-radius); &--success { --toast-progress-color: var(--alert-default-border-success); @@ -50,8 +50,8 @@ tedi-toast { .tedi-toast-container { position: fixed; inset: 0; - pointer-events: none; z-index: var(--z-index-toast, 1050); + pointer-events: none; &__position { position: absolute; @@ -96,8 +96,8 @@ tedi-toast { &--bottom-left { bottom: var(--toast-margin-bottom); left: var(--toast-margin-right); - align-items: flex-start; flex-direction: column-reverse; + align-items: flex-start; tedi-toast { animation: toast-slide-in-left 0.3s ease-out forwards; @@ -109,10 +109,10 @@ tedi-toast { } &--bottom-right { - bottom: var(--toast-margin-bottom); right: var(--toast-margin-right); - align-items: flex-end; + bottom: var(--toast-margin-bottom); flex-direction: column-reverse; + align-items: flex-end; tedi-toast { animation: toast-slide-in-right 0.3s ease-out forwards; @@ -130,10 +130,10 @@ tedi-toast { &--top-right, &--bottom-left, &--bottom-right { - left: var(--toast-margin-left); right: var(--toast-margin-right); - transform: none; + left: var(--toast-margin-left); align-items: stretch; + transform: none; tedi-toast { width: 100%; diff --git a/tedi/components/tags/index.ts b/tedi/components/tags/index.ts index 2a0e388a9..84d7f3348 100644 --- a/tedi/components/tags/index.ts +++ b/tedi/components/tags/index.ts @@ -1 +1,2 @@ export * from "./tag/tag.component"; +export * from "./status-badge/status-badge.component"; diff --git a/tedi/components/tags/status-badge/status-badge.component.ts b/tedi/components/tags/status-badge/status-badge.component.ts index 09d318ae7..21d66a4dc 100644 --- a/tedi/components/tags/status-badge/status-badge.component.ts +++ b/tedi/components/tags/status-badge/status-badge.component.ts @@ -7,7 +7,7 @@ import { computed, inject, } from "@angular/core"; -import { IconComponent } from "@tedi-design-system/angular/tedi"; +import { IconComponent } from "../../base/icon/icon.component"; import { _IdGenerator } from "@angular/cdk/a11y"; export type StatusBadgeColor = diff --git a/tedi/components/tags/status-badge/status-badge.stories.ts b/tedi/components/tags/status-badge/status-badge.stories.ts index 9db59e673..127f47460 100644 --- a/tedi/components/tags/status-badge/status-badge.stories.ts +++ b/tedi/components/tags/status-badge/status-badge.stories.ts @@ -4,21 +4,21 @@ import { moduleMetadata, argsToTemplate, } from "@storybook/angular"; -import { IconComponent, TextComponent } from "tedi/components/base"; -import { ButtonComponent } from "tedi/components/buttons"; -import { ColComponent, RowComponent } from "tedi/components/helpers"; import { - StatusBadgeColor, + IconComponent, + TextComponent, + ButtonComponent, StatusBadgeComponent, - StatusBadgeSize, - StatusBadgeStatus, - StatusBadgeVariant, -} from "./status-badge.component"; -import { + ColComponent, + RowComponent, TooltipComponent, TooltipContentComponent, TooltipTriggerComponent, -} from "tedi/components/overlay/tooltip"; + StatusBadgeColor, + StatusBadgeSize, + StatusBadgeStatus, + StatusBadgeVariant, +} from "@tedi-design-system/angular/tedi"; const colors: StatusBadgeColor[] = [ "neutral",