From 849db17ac02dd70093be363330052aa3d8e23eca Mon Sep 17 00:00:00 2001 From: m2rt Date: Tue, 27 Jan 2026 16:59:17 +0200 Subject: [PATCH 1/5] feat(tag): new tedi-ready component #297 also added ariaLabel input to closing-button, refactored spinner svg rendering --- .../closing-button.component.ts | 11 +- .../closing-button/closing-button.spec.ts | 8 + tedi/components/index.ts | 1 + .../loader/spinner/spinner.component.html | 7 +- .../loader/spinner/spinner.component.scss | 34 ++- tedi/components/tags/index.ts | 1 + tedi/components/tags/tag/tag.component.html | 24 ++ tedi/components/tags/tag/tag.component.scss | 60 +++++ .../components/tags/tag/tag.component.spec.ts | 126 ++++++++++ tedi/components/tags/tag/tag.component.ts | 68 ++++++ tedi/components/tags/tag/tag.stories.ts | 229 ++++++++++++++++++ 11 files changed, 549 insertions(+), 20 deletions(-) create mode 100644 tedi/components/tags/index.ts create mode 100644 tedi/components/tags/tag/tag.component.html create mode 100644 tedi/components/tags/tag/tag.component.scss create mode 100644 tedi/components/tags/tag/tag.component.spec.ts create mode 100644 tedi/components/tags/tag/tag.component.ts create mode 100644 tedi/components/tags/tag/tag.stories.ts diff --git a/tedi/components/buttons/closing-button/closing-button.component.ts b/tedi/components/buttons/closing-button/closing-button.component.ts index 1c9fa1045..2e5aaf3d5 100644 --- a/tedi/components/buttons/closing-button/closing-button.component.ts +++ b/tedi/components/buttons/closing-button/closing-button.component.ts @@ -19,8 +19,8 @@ export type ClosingButtonIconSize = 18 | 24; templateUrl: "./closing-button.component.html", styleUrl: "./closing-button.component.scss", host: { - "[title]": "title()", - "[attr.aria-label]": "title()", + "[title]": "ariaLabel() || _defaultLabel()", + "[attr.aria-label]": "ariaLabel() || _defaultLabel()", "[class.tedi-closing-button]": "true", "[class.tedi-closing-button--small]": "size() === 'small'", }, @@ -41,6 +41,11 @@ export class ClosingButtonComponent { */ iconSize = input(24); + /** + * ARIA label to override default label "close" + */ + readonly ariaLabel = input(); + private translationService = inject(TediTranslationService); - title = this.translationService.track("close"); + private readonly _defaultLabel = this.translationService.track("close"); } diff --git a/tedi/components/buttons/closing-button/closing-button.spec.ts b/tedi/components/buttons/closing-button/closing-button.spec.ts index b710337ab..4bccafda8 100644 --- a/tedi/components/buttons/closing-button/closing-button.spec.ts +++ b/tedi/components/buttons/closing-button/closing-button.spec.ts @@ -47,4 +47,12 @@ describe("ClosingButtonComponent", () => { expect(buttonElement.getAttribute("title")).toBe("Sulge"); expect(buttonElement.getAttribute("aria-label")).toBe("Sulge"); }); + + it("should override default aria-label when provided", () => { + fixture.componentRef.setInput("ariaLabel", "Eemalda"); + fixture.detectChanges(); + + expect(buttonElement.getAttribute("title")).toBe("Eemalda"); + expect(buttonElement.getAttribute("aria-label")).toBe("Eemalda"); + }); }); diff --git a/tedi/components/index.ts b/tedi/components/index.ts index 30dd183c0..b92d21eb8 100644 --- a/tedi/components/index.ts +++ b/tedi/components/index.ts @@ -8,3 +8,4 @@ export * from "./loader"; export * from "./navigation"; export * from "./overlay"; export * from "./notifications"; +export * from "./tags"; diff --git a/tedi/components/loader/spinner/spinner.component.html b/tedi/components/loader/spinner/spinner.component.html index ee0641653..8b01f0fa6 100644 --- a/tedi/components/loader/spinner/spinner.component.html +++ b/tedi/components/loader/spinner/spinner.component.html @@ -1,9 +1,8 @@ - + + +} + + + + + +@if (loading()) { + + + +} @else if (closable()) { + +} diff --git a/tedi/components/tags/tag/tag.component.scss b/tedi/components/tags/tag/tag.component.scss new file mode 100644 index 000000000..74909ba5f --- /dev/null +++ b/tedi/components/tags/tag/tag.component.scss @@ -0,0 +1,60 @@ +.tedi-tag { + --_background-color: var(--tag-primary-background); + --_border-color: var(--tag-primary-border); + --_border-width: 1px; + --_line-height: var(--body-small-regular-line-height); + + display: inline-flex; + gap: var(--tag-default-padding-x); + align-items: flex-start; + padding: 0 var(--tag-default-padding-x); + overflow: hidden; + font-family: var(--family-default); + font-size: var(--body-small-regular-size); + font-weight: var(--body-small-regular-weight); + line-height: var(--_line-height); + color: var(--general-text-primary); + background-color: var(--_background-color); + border: var(--_border-width) solid; + border-color: var(--_border-color); + border-radius: var(--tag-default-radius); + + + &__content { + padding: calc(var(--tag-default-padding-y) - var(--_border-width)) 0; + } + + &__icon-wrapper { + line-height: var(--_line-height); + } + + &__spinner-wrapper { + display: flex; + align-items: center; + height: calc(var(--_line-height) + var(--tag-default-padding-y)); + + tedi-spinner { + --tedi-spinner-size: 12; + --tedi-spinner-stroke: 2; + } + } + + &--closable:not(.tedi-tag--loading) { + padding-right: 0; + + .tedi-closing-button { + margin-block: -1px; + margin-right: -1px; + } + } + + &--secondary { + --_background-color: var(--tag-secondary-background); + --_border-color: var(--tag-secondary-border); + } + + &--danger { + --_background-color: var(--tag-danger-background); + --_border-color: var(--tag-danger-border); + } +} diff --git a/tedi/components/tags/tag/tag.component.spec.ts b/tedi/components/tags/tag/tag.component.spec.ts new file mode 100644 index 000000000..63047519d --- /dev/null +++ b/tedi/components/tags/tag/tag.component.spec.ts @@ -0,0 +1,126 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { TagComponent, TagType } from "./tag.component"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +describe("TagComponent", () => { + let component: TagComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TagComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }).compileComponents(); + + fixture = TestBed.createComponent(TagComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should have default values", () => { + expect(component.loading()).toBe(false); + expect(component.closable()).toBe(false); + expect(component.type()).toBe("primary"); + }); + + it("should have tedi-tag class", () => { + expect(fixture.nativeElement.classList).toContain("tedi-tag"); + }); + + it("should apply correct type class", () => { + const types: TagType[] = ["primary", "secondary", "danger"]; + + for (const type of types) { + fixture.componentRef.setInput("type", type); + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain(`tedi-tag--${type}`); + } + }); + + it("should show danger icon when type is danger", () => { + fixture.componentRef.setInput("type", "danger"); + fixture.detectChanges(); + + const iconElement = fixture.nativeElement.querySelector("tedi-icon"); + expect(iconElement).toBeTruthy(); + expect(iconElement.textContent).toBe("error"); + }); + + it("should not show danger icon when type is not danger", () => { + fixture.componentRef.setInput("type", "primary"); + fixture.detectChanges(); + + const iconWrapper = fixture.nativeElement.querySelector(".tedi-tag__icon-wrapper"); + expect(iconWrapper).toBeFalsy(); + }); + + it("should show spinner when loading is true", () => { + fixture.componentRef.setInput("loading", true); + fixture.detectChanges(); + + const spinner = fixture.nativeElement.querySelector("tedi-spinner"); + expect(spinner).toBeTruthy(); + expect(fixture.nativeElement.classList).toContain("tedi-tag--loading"); + }); + + it("should not show spinner when loading is false", () => { + fixture.componentRef.setInput("loading", false); + fixture.detectChanges(); + + const spinner = fixture.nativeElement.querySelector("tedi-spinner"); + expect(spinner).toBeFalsy(); + }); + + it("should show close button when closable is true", () => { + fixture.componentRef.setInput("closable", true); + fixture.detectChanges(); + + const closeButton = fixture.nativeElement.querySelector("[tedi-closing-button]"); + expect(closeButton).toBeTruthy(); + expect(fixture.nativeElement.classList).toContain("tedi-tag--closable"); + }); + + it("should not show close button when closable is false", () => { + fixture.componentRef.setInput("closable", false); + fixture.detectChanges(); + + const closeButton = fixture.nativeElement.querySelector("[tedi-closing-button]"); + expect(closeButton).toBeFalsy(); + }); + + it("should not show close button when loading is true even if closable is true", () => { + fixture.componentRef.setInput("closable", true); + fixture.componentRef.setInput("loading", true); + fixture.detectChanges(); + + const closeButton = fixture.nativeElement.querySelector("[tedi-closing-button]"); + const spinner = fixture.nativeElement.querySelector("tedi-spinner"); + + expect(closeButton).toBeFalsy(); + expect(spinner).toBeTruthy(); + }); + + it("should emit closed event when close button is clicked", () => { + fixture.componentRef.setInput("closable", true); + fixture.detectChanges(); + + const closedSpy = jest.fn(); + component.closed.subscribe(closedSpy); + + const closeButton = fixture.nativeElement.querySelector("[tedi-closing-button]"); + closeButton.click(); + fixture.detectChanges(); + + expect(closedSpy).toHaveBeenCalled(); + }); + + it("should render content", () => { + const contentElement = fixture.nativeElement.querySelector(".tedi-tag__content"); + expect(contentElement).toBeTruthy(); + }); +}); diff --git a/tedi/components/tags/tag/tag.component.ts b/tedi/components/tags/tag/tag.component.ts new file mode 100644 index 000000000..ada5874bc --- /dev/null +++ b/tedi/components/tags/tag/tag.component.ts @@ -0,0 +1,68 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + ViewEncapsulation, +} from "@angular/core"; +import { IconComponent } from "../../base/icon/icon.component"; +import { SpinnerComponent } from "../../loader/spinner/spinner.component"; +import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; +import { ClosingButtonComponent } from "../../buttons/closing-button/closing-button.component"; + +export type TagType = "primary" | "secondary" | "danger"; + +@Component({ + selector: "tedi-tag", + imports: [SpinnerComponent, IconComponent, ClosingButtonComponent, TediTranslationPipe], + templateUrl: "./tag.component.html", + styleUrl: "./tag.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class.tedi-tag]": "true", + "[class.tedi-tag--loading]": "loading()", + "[class.tedi-tag--closable]": "closable()", + "[class]": "classes()", + }, +}) +export class TagComponent { + /** + * Whether the tag is in loading state. + * When true, a spinner will be displayed inside the tag. + * @default false + */ + loading = input(false); + + /** + * Whether the tag can be closed. + * When true, a close button will be displayed that emits the 'closed' event when clicked. + * @default false + */ + closable = input(false); + + /** + * The visual style of the tag. + * Possible values: 'primary', 'secondary', 'danger' + * @default "primary" + */ + type = input("primary"); + + /** + * Event emitted when the close button is clicked. + */ + closed = output(); + + classes = computed(() => { + const classList = []; + if (this.type()) { + classList.push(`tedi-tag--${this.type()}`); + } + return classList.join(" "); + }); + + handleClose(event: Event) { + this.closed.emit(event); + } +} diff --git a/tedi/components/tags/tag/tag.stories.ts b/tedi/components/tags/tag/tag.stories.ts new file mode 100644 index 000000000..659ef5fd3 --- /dev/null +++ b/tedi/components/tags/tag/tag.stories.ts @@ -0,0 +1,229 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { TagComponent } from "./tag.component"; +import { SeparatorComponent } from "../../helpers/separator/separator.component"; +import { RowComponent } from "../../helpers/grid/row/row.component"; +import { ColComponent } from "../../helpers/grid/col/col.component"; +import { VerticalSpacingDirective } from "../../../directives/vertical-spacing/vertical-spacing.directive"; + +/** + * Figma ↗
+ * Zeroheight ↗ + * The Tag component is used to label, categorize, or organize items using keywords. + */ +export default { + title: "TEDI-Ready/Components/Tags/Tag", + component: TagComponent, + decorators: [ + moduleMetadata({ + imports: [TagComponent, SeparatorComponent, RowComponent, ColComponent, VerticalSpacingDirective], + }), + ], + render: (props) => ({ + props, + template: ` + + {{content}} + + `, + }), + args: { + type: "primary", + loading: false, + closable: false, + content: "Tag Content", + }, + argTypes: { + loading: { + control: "boolean", + description: "Whether the tag is in loading state.", + table: { + defaultValue: { summary: "false" }, + type: { summary: "boolean" }, + category: "inputs", + }, + }, + closable: { + control: "boolean", + description: "Whether the tag can be closed.", + table: { + defaultValue: { summary: "false" }, + type: { summary: "boolean" }, + category: "inputs", + }, + }, + content: { + control: "text", + description: "The content of the tag.", + table: { + category: "story-only", + }, + }, + type: { + control: "select", + options: ["primary", "secondary", "danger"], + description: "The type of the tag.", + table: { + defaultValue: { summary: "primary" }, + type: { summary: "string" }, + category: "inputs", + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithCloseButton: Story = { + render: () => ({ + template: ` + + Tag + + `, + }), +}; + +export const WithLoader: Story = { + render: () => ({ + template: ` + + taotlus_scan_lk_1.pdf + + `, + }), +}; + +export const WithInvalidIcon: Story = { + render: () => ({ + template: ` + + + + taotlus_scan_lk_1.pdf + + + + + taotlus_scan_lk_1.pdf + + + + `, + }), +}; + +export const Primary: Story = { + render: (props) => ({ + props, + template: ` + + + + Tag + + + + + Tag + + + + + taotlus_scan_lk_1.pdf + + + + + Tag with a very long text but little room + + + + + Tag with a very long text but little room + + + + `, + }), + args: { + type: "primary", + }, +}; + +export const Secondary: Story = { + render: (props) => ({ + props, + template: ` + + + + Tag + + + + + Tag + + + + + taotlus_scan_lk_1.pdf + + + + + Tag with a very long text but little room + + + + + Tag with a very long text but little room + + + + `, + }), + args: { + type: "secondary", + }, +}; + +export const Danger: Story = { + render: (props) => ({ + props, + template: ` + + + + Tag + + + + + Tag + + + + + taotlus_scan_lk_1.pdf + + + + + Tag with a very long text but little room + + + + + Tag with a very long text but little room + + + + `, + }), + args: { + type: "danger", + }, +}; From 8da33513bef36054c6cc7dd07e1e5a82ea9ec18f Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 29 Jan 2026 14:26:33 +0200 Subject: [PATCH 2/5] feat(tag): new tedi-ready component #297 added aria attributes to tag closing button --- tedi/components/tags/tag/tag.component.html | 4 +++- tedi/components/tags/tag/tag.component.ts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tedi/components/tags/tag/tag.component.html b/tedi/components/tags/tag/tag.component.html index ae0d0faba..3546813ce 100644 --- a/tedi/components/tags/tag/tag.component.html +++ b/tedi/components/tags/tag/tag.component.html @@ -4,7 +4,7 @@ } - + @@ -19,6 +19,8 @@ size="small" [iconSize]="18" (click)="handleClose($event)" + [attr.aria-describedby]="uniqueId" + [ariaLabel]="'remove' | tediTranslate" > } diff --git a/tedi/components/tags/tag/tag.component.ts b/tedi/components/tags/tag/tag.component.ts index ada5874bc..bb8b2c832 100644 --- a/tedi/components/tags/tag/tag.component.ts +++ b/tedi/components/tags/tag/tag.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + inject, input, output, ViewEncapsulation, @@ -10,6 +11,7 @@ import { IconComponent } from "../../base/icon/icon.component"; import { SpinnerComponent } from "../../loader/spinner/spinner.component"; import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; import { ClosingButtonComponent } from "../../buttons/closing-button/closing-button.component"; +import { _IdGenerator } from '@angular/cdk/a11y'; export type TagType = "primary" | "secondary" | "danger"; @@ -28,6 +30,8 @@ export type TagType = "primary" | "secondary" | "danger"; }, }) export class TagComponent { + readonly idGenerator = inject(_IdGenerator); + readonly uniqueId = this.idGenerator.getId('tedi-tag'); /** * Whether the tag is in loading state. * When true, a spinner will be displayed inside the tag. From b54f2753942e28a0b4ae89165633ca020008832c Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 29 Jan 2026 17:52:49 +0200 Subject: [PATCH 3/5] feat(tag): new tedi-ready component #297 changed border radius of close inside tag --- .../buttons/closing-button/closing-button.component.scss | 4 ++-- tedi/components/tags/tag/tag.component.scss | 1 + tedi/components/tags/tag/tag.stories.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tedi/components/buttons/closing-button/closing-button.component.scss b/tedi/components/buttons/closing-button/closing-button.component.scss index 1e92fd5da..afc00b59b 100644 --- a/tedi/components/buttons/closing-button/closing-button.component.scss +++ b/tedi/components/buttons/closing-button/closing-button.component.scss @@ -23,8 +23,8 @@ } &:focus-visible { - outline: 2px solid var(--button-main-primary-border-focus); - outline-offset: -2px; + outline: none; + box-shadow: inset 0 0 0 2px var(--button-main-primary-border-focus); } &--small { diff --git a/tedi/components/tags/tag/tag.component.scss b/tedi/components/tags/tag/tag.component.scss index 74909ba5f..3a2f0defd 100644 --- a/tedi/components/tags/tag/tag.component.scss +++ b/tedi/components/tags/tag/tag.component.scss @@ -45,6 +45,7 @@ .tedi-closing-button { margin-block: -1px; margin-right: -1px; + border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; } } diff --git a/tedi/components/tags/tag/tag.stories.ts b/tedi/components/tags/tag/tag.stories.ts index 659ef5fd3..2025e86d0 100644 --- a/tedi/components/tags/tag/tag.stories.ts +++ b/tedi/components/tags/tag/tag.stories.ts @@ -30,7 +30,7 @@ export default { type: "primary", loading: false, closable: false, - content: "Tag Content", + content: "Tag", }, argTypes: { loading: { From a3cd0ee2d981d90bb22990ba654bb5270a455a07 Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 29 Jan 2026 18:01:20 +0200 Subject: [PATCH 4/5] feat(tag): new tedi-ready component #297 tag border styling changes --- .../closing-button/closing-button.component.scss | 4 ++-- tedi/components/tags/tag/tag.component.scss | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tedi/components/buttons/closing-button/closing-button.component.scss b/tedi/components/buttons/closing-button/closing-button.component.scss index afc00b59b..1e92fd5da 100644 --- a/tedi/components/buttons/closing-button/closing-button.component.scss +++ b/tedi/components/buttons/closing-button/closing-button.component.scss @@ -23,8 +23,8 @@ } &:focus-visible { - outline: none; - box-shadow: inset 0 0 0 2px var(--button-main-primary-border-focus); + outline: 2px solid var(--button-main-primary-border-focus); + outline-offset: -2px; } &--small { diff --git a/tedi/components/tags/tag/tag.component.scss b/tedi/components/tags/tag/tag.component.scss index 3a2f0defd..01bf9d813 100644 --- a/tedi/components/tags/tag/tag.component.scss +++ b/tedi/components/tags/tag/tag.component.scss @@ -1,27 +1,23 @@ .tedi-tag { --_background-color: var(--tag-primary-background); --_border-color: var(--tag-primary-border); - --_border-width: 1px; --_line-height: var(--body-small-regular-line-height); display: inline-flex; gap: var(--tag-default-padding-x); align-items: flex-start; padding: 0 var(--tag-default-padding-x); - overflow: hidden; font-family: var(--family-default); font-size: var(--body-small-regular-size); font-weight: var(--body-small-regular-weight); line-height: var(--_line-height); color: var(--general-text-primary); background-color: var(--_background-color); - border: var(--_border-width) solid; - border-color: var(--_border-color); border-radius: var(--tag-default-radius); - + box-shadow: inset 0 0 0 1px var(--_border-color); &__content { - padding: calc(var(--tag-default-padding-y) - var(--_border-width)) 0; + padding: var(--tag-default-padding-y) 0; } &__icon-wrapper { @@ -43,8 +39,6 @@ padding-right: 0; .tedi-closing-button { - margin-block: -1px; - margin-right: -1px; border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; } } From 2d4237dc05ee80c84ad7e850269ab76c8b1d2e29 Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 30 Jan 2026 08:50:21 +0200 Subject: [PATCH 5/5] feat(tag): new tedi-ready component #297 story changes --- .../components/tags/tag/tag.component.ts | 3 ++ tedi/components/tags/tag/tag.stories.ts | 39 ------------------- 2 files changed, 3 insertions(+), 39 deletions(-) diff --git a/community/components/tags/tag/tag.component.ts b/community/components/tags/tag/tag.component.ts index 0c0afe7d9..b0b028985 100644 --- a/community/components/tags/tag/tag.component.ts +++ b/community/components/tags/tag/tag.component.ts @@ -14,6 +14,9 @@ import { export type TagType = "primary" | "secondary" | "danger"; +/** + * @deprecated Use Tag from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ selector: "tedi-tag", imports: [SpinnerComponent, IconComponent, TediTranslationPipe], diff --git a/tedi/components/tags/tag/tag.stories.ts b/tedi/components/tags/tag/tag.stories.ts index 2025e86d0..579266c3b 100644 --- a/tedi/components/tags/tag/tag.stories.ts +++ b/tedi/components/tags/tag/tag.stories.ts @@ -75,45 +75,6 @@ type Story = StoryObj; export const Default: Story = {}; -export const WithCloseButton: Story = { - render: () => ({ - template: ` - - Tag - - `, - }), -}; - -export const WithLoader: Story = { - render: () => ({ - template: ` - - taotlus_scan_lk_1.pdf - - `, - }), -}; - -export const WithInvalidIcon: Story = { - render: () => ({ - template: ` - - - - taotlus_scan_lk_1.pdf - - - - - taotlus_scan_lk_1.pdf - - - - `, - }), -}; - export const Primary: Story = { render: (props) => ({ props,