diff --git a/angular.json b/angular.json index bbe347f17..e3cc6d5bb 100644 --- a/angular.json +++ b/angular.json @@ -48,7 +48,8 @@ "outputDir": "dist/storybook-static", "styles": [ "./src/styles/index", - "./node_modules/@tedi-design-system/core/index.scss" + "./node_modules/@tedi-design-system/core/index.scss", + "./node_modules/@tedi-design-system/core/tedi-storybook-styles.scss" ], "experimentalZoneless": true } diff --git a/community/components/tags/status-badge/status-badge.component.ts b/community/components/tags/status-badge/status-badge.component.ts index a7815cb50..302a9f5a7 100644 --- a/community/components/tags/status-badge/status-badge.component.ts +++ b/community/components/tags/status-badge/status-badge.component.ts @@ -21,6 +21,9 @@ export type StatusBadgeStatus = | "inactive" | "none"; +/** + * @deprecated Use StatusBadge from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ selector: "[tedi-status-badge]", templateUrl: "./status-badge.component.html", diff --git a/community/components/tags/status-badge/status-badge.stories.ts b/community/components/tags/status-badge/status-badge.stories.ts index e8fcf65d9..9f399a269 100644 --- a/community/components/tags/status-badge/status-badge.stories.ts +++ b/community/components/tags/status-badge/status-badge.stories.ts @@ -141,6 +141,9 @@ const meta: Meta = { type: "figma", url: "https://www.figma.com/file/jWiRIXhHRxwVdMSimKX2FF/TEDI-Design-System-(draft)?type=design&node-id=2385-24154&m=dev", }, + status: { + type: ["deprecated", "existsInTediReady"], + }, }, }; export default meta; diff --git a/community/components/tags/tag/tag.stories.ts b/community/components/tags/tag/tag.stories.ts index da4182fd3..4dd004017 100644 --- a/community/components/tags/tag/tag.stories.ts +++ b/community/components/tags/tag/tag.stories.ts @@ -63,6 +63,11 @@ export default { }, }, }, + parameters: { + status: { + type: ["deprecated", "existsInTediReady"], + }, + }, } as Meta; type Story = StoryObj; diff --git a/tedi/components/tags/status-badge/status-badge.component.html b/tedi/components/tags/status-badge/status-badge.component.html new file mode 100644 index 000000000..936e89337 --- /dev/null +++ b/tedi/components/tags/status-badge/status-badge.component.html @@ -0,0 +1,30 @@ +@if (title()) { + + + +} @else { +
+ +
+} + + + @if (icon()) { + + } + + @if (text()) { + {{ text() }} + } + diff --git a/tedi/components/tags/status-badge/status-badge.component.scss b/tedi/components/tags/status-badge/status-badge.component.scss new file mode 100644 index 000000000..6ac56b4c7 --- /dev/null +++ b/tedi/components/tags/status-badge/status-badge.component.scss @@ -0,0 +1,104 @@ +$badge-colors: ( + "neutral", + "brand", + "accent", + "success", + "danger", + "warning", + "transparent" +); +$badge-variants: ("filled", "filled-bordered", "bordered"); +$badge-status-colors: ("inactive", "success", "danger", "warning"); + +.tedi-status-badge { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--tag-default-padding-y) var(--tag-default-padding-x); + font-size: var(--body-small-regular-size); + font-weight: var(--body-small-regular-weight); + line-height: var(--body-small-regular-line-height); + color: var(--_status-badge-text); + text-decoration: none; + background: var(--_status-badge-background); + border-radius: var(--tag-default-radius); + box-shadow: var(--_status-badge-box-shadow); + + &__text { + min-width: fit-content; + padding: 0 var(--tag-default-padding-x); + } + + &--large { + min-width: var(--tag-status-large-min-width); + padding: var(--tag-status-large-padding-y) var(--tag-status-large-padding-x); + } + + @each $variant in $badge-variants { + &.tedi-status-badge--variant-#{$variant} { + @each $color in $badge-colors { + &.tedi-status-badge--color-#{$color} { + --_status-badge-text: var(--status-badge-text-#{$color}); + --_status-badge-box-shadow: inset + 0 + 0 + 0 + 1px + var(--status-badge-border-#{$color}); + + @if $variant == "bordered" { + --_status-badge-background: transparent; + } @else { + --_status-badge-background: var( + --status-badge-background-#{$color} + ); + } + + @if $variant == "filled" { + --_status-badge-box-shadow: none; + } + } + } + } + } + + &--status { + &::before { + position: absolute; + top: -0.125rem; + right: -0.125rem; + z-index: 1; + width: var(--status-indicator-sm); + height: var(--status-indicator-sm); + content: ""; + background-color: var(--_status-badge--indicator-background); + border: 1px solid var(--status-badge-indicator-border); + border-radius: 50%; + } + + &.tedi-status-badge--large::before { + width: var(--status-indicator-lg); + height: var(--status-indicator-lg); + } + + @each $status in $badge-status-colors { + &-#{$status}::before { + --_status-badge--indicator-background: var( + --status-badge-indicator-#{$status} + ); + } + } + } + + .tedi-status-badge__icon { + color: inherit; + } + + &__icon-only { + display: inline-flex; + align-items: center; + padding: var(--tag-status-icon-padding-y) var(--tag-status-icon-padding-x); + line-height: var(--body-small-regular-line-height); + } +} diff --git a/tedi/components/tags/status-badge/status-badge.component.spec.ts b/tedi/components/tags/status-badge/status-badge.component.spec.ts new file mode 100644 index 000000000..43eae2407 --- /dev/null +++ b/tedi/components/tags/status-badge/status-badge.component.spec.ts @@ -0,0 +1,115 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { StatusBadgeComponent } from "./status-badge.component"; + +describe("StatusBadgeComponent", () => { + let fixture: ComponentFixture; + let component: StatusBadgeComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatusBadgeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StatusBadgeComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("renders a div if title is not provided", () => { + fixture.componentRef.setInput("text", "Text"); + fixture.detectChanges(); + + const div = element.querySelector("div.tedi-status-badge"); + expect(div).not.toBeNull(); + expect(element.querySelector("abbr")).toBeNull(); + }); + + it("renders the text when provided", () => { + fixture.componentRef.setInput("text", "Text"); + fixture.detectChanges(); + + const span = element.querySelector(".tedi-status-badge__text"); + expect(span).not.toBeNull(); + expect(span?.textContent).toBe("Text"); + }); + + it("renders an abbr if title is provided", () => { + fixture.componentRef.setInput("title", "Title"); + fixture.componentRef.setInput("text", "Text"); + fixture.detectChanges(); + + const abbr = element.querySelector("abbr.tedi-status-badge"); + expect(abbr).not.toBeNull(); + expect(abbr?.getAttribute("title")).toBe("Title"); + }); + + it("applies classes for color, variant, size, and status", () => { + fixture.componentRef.setInput("color", "brand"); + fixture.componentRef.setInput("variant", "filled-bordered"); + fixture.componentRef.setInput("size", "large"); + fixture.componentRef.setInput("status", "success"); + fixture.detectChanges(); + + const badge = element.querySelector(".tedi-status-badge"); + expect(badge).not.toBeNull(); + + expect(badge?.classList.contains("tedi-status-badge--color-brand")).toBe( + true, + ); + expect( + badge?.classList.contains("tedi-status-badge--variant-filled-bordered"), + ).toBe(true); + expect(badge?.classList.contains("tedi-status-badge--large")).toBe(true); + expect(badge?.classList.contains("tedi-status-badge--status")).toBe(true); + expect(badge?.classList.contains("tedi-status-badge--status-success")).toBe( + true, + ); + }); + + it("computes aria-live based on role", () => { + fixture.componentRef.setInput("role", "alert"); + fixture.detectChanges(); + expect(component.ariaLive()).toBe("assertive"); + + fixture.componentRef.setInput("role", "status"); + fixture.detectChanges(); + expect(component.ariaLive()).toBe("polite"); + + fixture.componentRef.setInput("role", ""); + fixture.detectChanges(); + expect(component.ariaLive()).toBeNull(); + }); + + it("renders icon when icon is provided", () => { + fixture.componentRef.setInput("icon", "edit"); + fixture.detectChanges(); + + const icon = element.querySelector("tedi-icon"); + expect(icon).not.toBeNull(); + }); + + it("adds icon-only class when only icon is present", () => { + fixture.componentRef.setInput("icon", "edit"); + fixture.componentRef.setInput("text", ""); + fixture.detectChanges(); + + const badge = element.querySelector(".tedi-status-badge"); + expect(badge).not.toBeNull(); + expect(badge?.classList.contains("tedi-status-badge__icon-only")).toBe( + true, + ); + }); + + it("should include custom class in computed classes", () => { + fixture.componentRef.setInput("class", "custom-class"); + fixture.detectChanges(); + + const classes = component.classes(); + expect(classes).toContain("custom-class"); + }); +}); diff --git a/tedi/components/tags/status-badge/status-badge.component.ts b/tedi/components/tags/status-badge/status-badge.component.ts new file mode 100644 index 000000000..09d318ae7 --- /dev/null +++ b/tedi/components/tags/status-badge/status-badge.component.ts @@ -0,0 +1,123 @@ +import { CommonModule, NgClass } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + input, + computed, + inject, +} from "@angular/core"; +import { IconComponent } from "@tedi-design-system/angular/tedi"; +import { _IdGenerator } from "@angular/cdk/a11y"; + +export type StatusBadgeColor = + | "neutral" + | "brand" + | "accent" + | "success" + | "danger" + | "warning" + | "transparent"; +export type StatusBadgeVariant = "filled" | "filled-bordered" | "bordered"; +export type StatusBadgeSize = "default" | "large"; +export type StatusBadgeStatus = "danger" | "success" | "warning" | "inactive"; + +@Component({ + selector: "tedi-status-badge", + standalone: true, + imports: [IconComponent, CommonModule, NgClass], + templateUrl: "./status-badge.component.html", + styleUrl: "./status-badge.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StatusBadgeComponent { + readonly idGenerator = inject(_IdGenerator); + readonly uniqueId = this.idGenerator.getId("tedi-status-badge"); + /** + * The text to be displayed inside the StatusBadge. + */ + text = input(""); + /** + * Additional classes to apply custom styles to the StatusBadge. + */ + class = input(); + /** + * Provides the full text or description when the Badge represents an abbreviation. + * This is typically shown as a tooltip on hover. + */ + title = input(); + /** + * ARIA role attribute for accessibility. + */ + role = input(); + /** + * Specifies the color scheme of the StatusBadge. + * @default neutral + */ + color = input("neutral"); + /** + * Determines the style or visual type of the StatusBadge. + * @default filled + */ + variant = input("filled"); + /** + * Specifies the size of the StatusBadge. + * @default default + */ + size = input("default"); + /** + * StatusBadge status indicator. + */ + status = input(); + /** + * The name of the icon to be displayed inside the StatusBadge. The icon is rendered using the `Icon` component. + */ + icon = input(""); + + classes = computed(() => { + const classList = ["tedi-status-badge"]; + + if (this.color()) { + classList.push(`tedi-status-badge--color-${this.color()}`); + } + + if (this.variant()) { + classList.push(`tedi-status-badge--variant-${this.variant()}`); + } + + if (this.status()) { + classList.push( + "tedi-status-badge--status", + `tedi-status-badge--status-${this.status()}`, + ); + } + + if (this.size() === "large") { + classList.push("tedi-status-badge--large"); + } + + const hasText = !!this.text()?.trim(); + const hasIcon = !!this.icon()?.trim(); + if (hasIcon && !hasText) { + classList.push("tedi-status-badge__icon-only"); + } + + const customClass = this.class(); + if (customClass) { + classList.push(customClass); + } + + return classList; + }); + + ariaLive = computed(() => { + if (this.role() === "alert") { + return "assertive"; + } + if (this.role() === "status") { + return "polite"; + } + return null; + }); +} diff --git a/tedi/components/tags/status-badge/status-badge.stories.ts b/tedi/components/tags/status-badge/status-badge.stories.ts new file mode 100644 index 000000000..9db59e673 --- /dev/null +++ b/tedi/components/tags/status-badge/status-badge.stories.ts @@ -0,0 +1,337 @@ +import { + Meta, + StoryObj, + 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, + StatusBadgeComponent, + StatusBadgeSize, + StatusBadgeStatus, + StatusBadgeVariant, +} from "./status-badge.component"; +import { + TooltipComponent, + TooltipContentComponent, + TooltipTriggerComponent, +} from "tedi/components/overlay/tooltip"; + +const colors: StatusBadgeColor[] = [ + "neutral", + "brand", + "accent", + "warning", + "danger", + "success", + "transparent", +]; + +const demoColors: Exclude[] = [ + "neutral", + "brand", + "accent", + "warning", + "danger", + "success", +]; + +const variants: StatusBadgeVariant[] = [ + "filled", + "filled-bordered", + "bordered", +]; + +const statuses: StatusBadgeStatus[] = [ + "inactive", + "success", + "warning", + "danger", +]; + +const colorToIconMap: Record = { + neutral: "edit", + brand: "send", + accent: "sync", + warning: "warning", + danger: "error", + success: "check", + transparent: "edit", +}; + +const statusToIconMap: Partial> = { + inactive: "edit", + success: "send", + warning: "sync", + danger: "error", +}; + +/** + * Figma ↗
+ * Zeroheight ↗

+ */ + +export default { + title: "TEDI-Ready/Components/Tags/StatusBadge", + decorators: [ + moduleMetadata({ + imports: [ + StatusBadgeComponent, + IconComponent, + TextComponent, + ButtonComponent, + RowComponent, + ColComponent, + TooltipComponent, + TooltipTriggerComponent, + TooltipContentComponent, + ], + }), + ], + argTypes: { + color: { + control: "select", + description: "Specifies the color scheme of the StatusBadge.", + options: colors, + table: { + category: "inputs", + type: { summary: "StatusBadgeColor" }, + defaultValue: { summary: "neutral" }, + }, + }, + variant: { + control: "radio", + description: "Determines the style or visual type of the StatusBadge.", + options: variants, + table: { + category: "inputs", + type: { summary: "StatusBadgeVariant" }, + defaultValue: { summary: "filled" }, + }, + }, + text: { + control: "text", + description: "The text to be displayed inside the StatusBadge.", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + icon: { + control: "text", + description: + "The name of the icon to be displayed inside the StatusBadge. The icon is rendered using the `Icon` component.", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + title: { + control: "text", + description: + "Provides the full text or description when the Badge represents an abbreviation. This is typically shown as a tooltip on hover.", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + role: { + control: "text", + description: "ARIA role attribute for accessibility.", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + size: { + control: "radio", + description: "Specifies the size of the StatusBadge.", + options: ["default", "large"] as StatusBadgeSize[], + table: { + category: "inputs", + type: { summary: "StatusBadgeSize" }, + defaultValue: { summary: "default" }, + }, + }, + status: { + control: "radio", + description: "StatusBadge status indicator.", + options: statuses, + table: { + category: "inputs", + type: { summary: "StatusBadgeStatus" }, + }, + }, + class: { + control: "text", + description: + "Additional classes to apply custom styles to the StatusBadge.", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + }, +} as Meta; + +export const Default: StoryObj = { + args: { + color: "neutral", + variant: "filled", + text: "Text", + size: "default", + }, + render: (args) => ({ + props: args, + template: ``, + }), +}; + +export const Size: StoryObj = { + render: () => ({ + template: ` + + + +

Default

+
+ + + + +
+ + +

Large

+
+ + + + +
+
+ `, + }), +}; + +export const Colors: StoryObj = { + render: () => ({ + props: { demoColors, variants, colorToIconMap }, + template: ` + + + +

{{ color }}

+
+ + + + + + + +
+
+ `, + }), +}; + +export const WithIndicator: StoryObj = { + render: () => ({ + props: { color: "neutral", statuses, variants, statusToIconMap }, + template: ` + + + +

{{ status }}

+
+ + + + + + + +
+
+ `, + }), +}; + +export const WithTooltip: StoryObj = { + args: { + icon: "warning", + color: "warning", + }, + render: (args) => ({ + props: args, + template: ` + + + + + + Icon-only badges should always have a tooltip to provide context and ensure accessibility. + + + `, + }), +};