diff --git a/community/components/tags/tag/tag.component.ts b/community/components/tags/tag/tag.component.ts index 0c0afe7d..b0b02898 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/buttons/closing-button/closing-button.component.ts b/tedi/components/buttons/closing-button/closing-button.component.ts index 1c9fa104..2e5aaf3d 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 b710337a..4bccafda 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 30dd183c..b92d21eb 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 ee064165..8b01f0fa 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 00000000..01bf9d81 --- /dev/null +++ b/tedi/components/tags/tag/tag.component.scss @@ -0,0 +1,55 @@ +.tedi-tag { + --_background-color: var(--tag-primary-background); + --_border-color: var(--tag-primary-border); + --_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); + 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-radius: var(--tag-default-radius); + box-shadow: inset 0 0 0 1px var(--_border-color); + + &__content { + padding: var(--tag-default-padding-y) 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 { + border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; + } + } + + &--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 00000000..63047519 --- /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 00000000..bb8b2c83 --- /dev/null +++ b/tedi/components/tags/tag/tag.component.ts @@ -0,0 +1,72 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + 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"; +import { _IdGenerator } from '@angular/cdk/a11y'; + +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 { + 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. + * @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 00000000..579266c3 --- /dev/null +++ b/tedi/components/tags/tag/tag.stories.ts @@ -0,0 +1,190 @@ +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", + }, + 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 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", + }, +};