diff --git a/.changeset/ix-3943-badge.md b/.changeset/ix-3943-badge.md new file mode 100644 index 00000000000..4d32c4e6e7f --- /dev/null +++ b/.changeset/ix-3943-badge.md @@ -0,0 +1,8 @@ +--- +'@siemens/ix': minor +'@siemens/ix-angular': minor +'@siemens/ix-react': minor +'@siemens/ix-vue': minor +--- + +Introduces `ix-badge` for overlay count and status indicators on anchors or as standalone badges, with semantic variants, outline mode, position/offset, optional attention animation, and standalone `tooltipText` support. diff --git a/AGENTS.md b/AGENTS.md index ba2a89afd6f..b73b408e2a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,8 @@ Targeted commands: - Use core component tests for component behavior, interaction, keyboard handling, accessibility, slots, events, and state changes. - Include accessibility coverage (`makeAxeBuilder`) and a basic hydration/render test in component test files. - Add or update unit tests for pure logic changes, component tests for interaction/accessibility changes, and visual tests only for meaningful UI or theme changes. -- Prefer Playwright locators, user-visible assertions, and deterministic waits over implementation-detail selectors and timeouts. +- Prefer Playwright **accessibility locators** (`getByRole`, `getByLabel`, `getByText`, accessible name/description assertions) for user-facing contracts. Fall back to tag/CSS selectors for custom elements without roles and for decorative chrome outside the a11y tree. +- Prefer deterministic waits over implementation-detail selectors and timeouts. ## Visual regression testing @@ -130,6 +131,7 @@ Targeted commands: ## Component-test conventions (core) - Use `regressionTest` from `@utils/test` (not plain Playwright `test`). +- Prefer accessibility locators (`getByRole`, `getByLabel`, `getByText`) when elements expose roles/names; use tag/CSS selectors for host state and decorative/`aria-hidden` chrome. See `.github/instructions/component-tests.instructions.md`. - Use Playwright locators (including for Shadow DOM assertions). - Avoid arbitrary timeouts (`waitForTimeout`). - Register icons via mount config; do not fetch static SVG files directly in tests. diff --git a/packages/angular-standalone-test-app/src/app/app.routes.ts b/packages/angular-standalone-test-app/src/app/app.routes.ts index d73a9d7b173..8e4893a92a7 100644 --- a/packages/angular-standalone-test-app/src/app/app.routes.ts +++ b/packages/angular-standalone-test-app/src/app/app.routes.ts @@ -52,6 +52,16 @@ export const routePaths: IxPreviewRoutes = { import('../preview-examples/avatar-image').then((m) => m.default), 'preview/avatar-initials': () => import('../preview-examples/avatar-initials').then((m) => m.default), + 'preview/badge': () => + import('../preview-examples/badge').then((m) => m.default), + 'preview/badge-counter': () => + import('../preview-examples/badge-counter').then((m) => m.default), + 'preview/badge-dot': () => + import('../preview-examples/badge-dot').then((m) => m.default), + 'preview/badge-label': () => + import('../preview-examples/badge-label').then((m) => m.default), + 'preview/badge-status-icon': () => + import('../preview-examples/badge-status-icon').then((m) => m.default), 'preview/blind': () => import('../preview-examples/blind').then((m) => m.default), 'preview/blind-header-actions': () => diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-counter.css b/packages/angular-standalone-test-app/src/preview-examples/badge-counter.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-counter.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-counter.html b/packages/angular-standalone-test-app/src/preview-examples/badge-counter.html new file mode 100644 index 00000000000..29149a2d264 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-counter.html @@ -0,0 +1,42 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-counter.ts b/packages/angular-standalone-test-app/src/preview-examples/badge-counter.ts new file mode 100644 index 00000000000..a8b4197470d --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-counter.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { IxBadge } from '@siemens/ix-angular/standalone'; + +@Component({ + selector: 'app-example', + imports: [IxBadge], + templateUrl: './badge-counter.html', + styleUrls: ['./badge-counter.css'], +}) +export default class BadgeCounter {} diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-dot.css b/packages/angular-standalone-test-app/src/preview-examples/badge-dot.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-dot.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-dot.html b/packages/angular-standalone-test-app/src/preview-examples/badge-dot.html new file mode 100644 index 00000000000..a6196e151f4 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-dot.html @@ -0,0 +1,42 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-dot.ts b/packages/angular-standalone-test-app/src/preview-examples/badge-dot.ts new file mode 100644 index 00000000000..b3d3296736d --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-dot.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { IxBadge } from '@siemens/ix-angular/standalone'; + +@Component({ + selector: 'app-example', + imports: [IxBadge], + templateUrl: './badge-dot.html', + styleUrls: ['./badge-dot.css'], +}) +export default class BadgeDot {} diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-label.css b/packages/angular-standalone-test-app/src/preview-examples/badge-label.css new file mode 100644 index 00000000000..e8543b9428b --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-label.css @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-label.html b/packages/angular-standalone-test-app/src/preview-examples/badge-label.html new file mode 100644 index 00000000000..179187e989a --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-label.html @@ -0,0 +1,34 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-label.ts b/packages/angular-standalone-test-app/src/preview-examples/badge-label.ts new file mode 100644 index 00000000000..ad0087fd265 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-label.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { IxBadge } from '@siemens/ix-angular/standalone'; +import { addIcons } from '@siemens/ix-icons'; +import { iconInfo } from '@siemens/ix-icons/icons'; + +@Component({ + selector: 'app-example', + imports: [IxBadge], + templateUrl: './badge-label.html', + styleUrls: ['./badge-label.css'], +}) +export default class BadgeLabel { + constructor() { + addIcons({ iconInfo }); + } +} diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.css b/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.html b/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.html new file mode 100644 index 00000000000..ebfacbd6941 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.html @@ -0,0 +1,32 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.ts b/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.ts new file mode 100644 index 00000000000..43256c57650 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge-status-icon.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { IxBadge } from '@siemens/ix-angular/standalone'; + +@Component({ + selector: 'app-example', + imports: [IxBadge], + templateUrl: './badge-status-icon.html', + styleUrls: ['./badge-status-icon.css'], +}) +export default class BadgeStatusIcon {} diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge.css b/packages/angular-standalone-test-app/src/preview-examples/badge.css new file mode 100644 index 00000000000..875f0b6238a --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge.css @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2rem; + margin-bottom: 1.5rem; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1.5rem; +} + +.styled { + width: 7rem; + --ix-badge-max-width: 7rem; +} + +.styled-ellipsis-4 { + width: 4rem; + --ix-badge-max-width: 4rem; +} diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge.html b/packages/angular-standalone-test-app/src/preview-examples/badge.html new file mode 100644 index 00000000000..5abda16314f --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge.html @@ -0,0 +1,31 @@ +
+ Review + + Bottom after + Messages + Device +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + +
diff --git a/packages/angular-standalone-test-app/src/preview-examples/badge.ts b/packages/angular-standalone-test-app/src/preview-examples/badge.ts new file mode 100644 index 00000000000..033f3960c34 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/badge.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { IxBadge, IxButton, IxIconButton } from '@siemens/ix-angular/standalone'; +import { addIcons } from '@siemens/ix-icons'; +import { iconInfo, iconStar } from '@siemens/ix-icons/icons'; + +@Component({ + selector: 'app-example', + imports: [IxBadge, IxButton, IxIconButton], + templateUrl: './badge.html', + styleUrls: ['./badge.css'], +}) +export default class Badge { + constructor() { + addIcons({ iconInfo, iconStar }); + } +} diff --git a/packages/angular-test-app/src/app/app-routing.module.ts b/packages/angular-test-app/src/app/app-routing.module.ts index e75fa426e54..b3de23a66cd 100644 --- a/packages/angular-test-app/src/app/app-routing.module.ts +++ b/packages/angular-test-app/src/app/app-routing.module.ts @@ -21,6 +21,11 @@ import ApplicationAppSwitchExample from '../preview-examples/application-app-swi import ApplicationBreakpointExample from '../preview-examples/application-breakpoints'; import ApplicationHeaderExample from '../preview-examples/application-header'; import Avatar from '../preview-examples/avatar'; +import Badge from '../preview-examples/badge'; +import BadgeCounter from '../preview-examples/badge-counter'; +import BadgeDot from '../preview-examples/badge-dot'; +import BadgeLabel from '../preview-examples/badge-label'; +import BadgeStatusIcon from '../preview-examples/badge-status-icon'; import AvatarImage from '../preview-examples/avatar-image'; import AvatarInitials from '../preview-examples/avatar-initials'; import Blind from '../preview-examples/blind'; @@ -337,6 +342,26 @@ const routes: Routes = [ path: 'avatar', component: Avatar, }, + { + path: 'badge', + component: Badge, + }, + { + path: 'badge-counter', + component: BadgeCounter, + }, + { + path: 'badge-dot', + component: BadgeDot, + }, + { + path: 'badge-label', + component: BadgeLabel, + }, + { + path: 'badge-status-icon', + component: BadgeStatusIcon, + }, { path: 'blind-header-actions', component: BlindHeaderActions, diff --git a/packages/angular-test-app/src/app/app.module.ts b/packages/angular-test-app/src/app/app.module.ts index 0d4dd91390d..600cb855076 100644 --- a/packages/angular-test-app/src/app/app.module.ts +++ b/packages/angular-test-app/src/app/app.module.ts @@ -30,6 +30,11 @@ import ApplicationAppSwitchExample from '../preview-examples/application-app-swi import ApplicationBreakpointExample from '../preview-examples/application-breakpoints'; import ApplicationHeaderExample from '../preview-examples/application-header'; import Avatar from '../preview-examples/avatar'; +import Badge from '../preview-examples/badge'; +import BadgeCounter from '../preview-examples/badge-counter'; +import BadgeDot from '../preview-examples/badge-dot'; +import BadgeLabel from '../preview-examples/badge-label'; +import BadgeStatusIcon from '../preview-examples/badge-status-icon'; import AvatarImage from '../preview-examples/avatar-image'; import AvatarInitials from '../preview-examples/avatar-initials'; import Blind from '../preview-examples/blind'; @@ -292,6 +297,11 @@ import WorkflowVertical from '../preview-examples/workflow-vertical'; AvatarImage, AvatarInitials, Avatar, + Badge, + BadgeCounter, + BadgeDot, + BadgeLabel, + BadgeStatusIcon, BlindHeaderActions, BlindVariants, Blind, diff --git a/packages/angular-test-app/src/preview-examples/badge-counter.css b/packages/angular-test-app/src/preview-examples/badge-counter.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-counter.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/angular-test-app/src/preview-examples/badge-counter.html b/packages/angular-test-app/src/preview-examples/badge-counter.html new file mode 100644 index 00000000000..29149a2d264 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-counter.html @@ -0,0 +1,42 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-test-app/src/preview-examples/badge-counter.ts b/packages/angular-test-app/src/preview-examples/badge-counter.ts new file mode 100644 index 00000000000..63cefcb7101 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-counter.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './badge-counter.html', + styleUrls: ['./badge-counter.css'], +}) +export default class BadgeCounter {} diff --git a/packages/angular-test-app/src/preview-examples/badge-dot.css b/packages/angular-test-app/src/preview-examples/badge-dot.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-dot.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/angular-test-app/src/preview-examples/badge-dot.html b/packages/angular-test-app/src/preview-examples/badge-dot.html new file mode 100644 index 00000000000..a6196e151f4 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-dot.html @@ -0,0 +1,42 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-test-app/src/preview-examples/badge-dot.ts b/packages/angular-test-app/src/preview-examples/badge-dot.ts new file mode 100644 index 00000000000..67471f0625b --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-dot.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './badge-dot.html', + styleUrls: ['./badge-dot.css'], +}) +export default class BadgeDot {} diff --git a/packages/angular-test-app/src/preview-examples/badge-label.css b/packages/angular-test-app/src/preview-examples/badge-label.css new file mode 100644 index 00000000000..e8543b9428b --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-label.css @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} diff --git a/packages/angular-test-app/src/preview-examples/badge-label.html b/packages/angular-test-app/src/preview-examples/badge-label.html new file mode 100644 index 00000000000..179187e989a --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-label.html @@ -0,0 +1,34 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-test-app/src/preview-examples/badge-label.ts b/packages/angular-test-app/src/preview-examples/badge-label.ts new file mode 100644 index 00000000000..9aab90a8aac --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-label.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { addIcons } from '@siemens/ix-icons'; +import { iconInfo } from '@siemens/ix-icons/icons'; + +addIcons({ iconInfo }); + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './badge-label.html', + styleUrls: ['./badge-label.css'], +}) +export default class BadgeLabel {} diff --git a/packages/angular-test-app/src/preview-examples/badge-status-icon.css b/packages/angular-test-app/src/preview-examples/badge-status-icon.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-status-icon.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/angular-test-app/src/preview-examples/badge-status-icon.html b/packages/angular-test-app/src/preview-examples/badge-status-icon.html new file mode 100644 index 00000000000..ebfacbd6941 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-status-icon.html @@ -0,0 +1,32 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/angular-test-app/src/preview-examples/badge-status-icon.ts b/packages/angular-test-app/src/preview-examples/badge-status-icon.ts new file mode 100644 index 00000000000..dc4674ba367 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge-status-icon.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './badge-status-icon.html', + styleUrls: ['./badge-status-icon.css'], +}) +export default class BadgeStatusIcon {} diff --git a/packages/angular-test-app/src/preview-examples/badge.css b/packages/angular-test-app/src/preview-examples/badge.css new file mode 100644 index 00000000000..875f0b6238a --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge.css @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2rem; + margin-bottom: 1.5rem; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1.5rem; +} + +.styled { + width: 7rem; + --ix-badge-max-width: 7rem; +} + +.styled-ellipsis-4 { + width: 4rem; + --ix-badge-max-width: 4rem; +} diff --git a/packages/angular-test-app/src/preview-examples/badge.html b/packages/angular-test-app/src/preview-examples/badge.html new file mode 100644 index 00000000000..5abda16314f --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge.html @@ -0,0 +1,31 @@ +
+ Review + + Bottom after + Messages + Device +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + +
diff --git a/packages/angular-test-app/src/preview-examples/badge.ts b/packages/angular-test-app/src/preview-examples/badge.ts new file mode 100644 index 00000000000..2b00985a311 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/badge.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { addIcons } from '@siemens/ix-icons'; +import { iconInfo, iconStar } from '@siemens/ix-icons/icons'; + +addIcons({ iconInfo, iconStar }); + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './badge.html', + styleUrls: ['./badge.css'], +}) +export default class Badge {} diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 1cadcb1b060..07d38cb729d 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -111,6 +111,29 @@ export class IxAvatar { export declare interface IxAvatar extends Components.IxAvatar {} +@ProxyCmp({ + inputs: ['alignLeft', 'ariaLabelIcon', 'background', 'badgeColor', 'border', 'enableAnimation', 'icon', 'label', 'offsetX', 'offsetY', 'outline', 'position', 'tooltipText', 'type', 'variant'] +}) +@Component({ + selector: 'ix-badge', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['alignLeft', 'ariaLabelIcon', 'background', 'badgeColor', 'border', 'enableAnimation', 'icon', 'label', 'offsetX', 'offsetY', 'outline', 'position', 'tooltipText', 'type', 'variant'], + standalone: false +}) +export class IxBadge { + protected el: HTMLIxBadgeElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxBadge extends Components.IxBadge {} + + @ProxyCmp({ inputs: ['collapsed', 'icon', 'label', 'sublabel', 'variant'] }) diff --git a/packages/angular/src/declare-components.ts b/packages/angular/src/declare-components.ts index c463b1265a6..4bd66630a88 100644 --- a/packages/angular/src/declare-components.ts +++ b/packages/angular/src/declare-components.ts @@ -6,6 +6,7 @@ export const DIRECTIVES = [ d.IxApplication, d.IxApplicationHeader, d.IxAvatar, + d.IxBadge, d.IxBlind, d.IxBreadcrumb, d.IxBreadcrumbItem, diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index 9d1ca8c30d4..6c406a74416 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -10,6 +10,7 @@ import { defineCustomElement as defineIxActionCard } from '@siemens/ix/component import { defineCustomElement as defineIxApplication } from '@siemens/ix/components/ix-application.js'; import { defineCustomElement as defineIxApplicationHeader } from '@siemens/ix/components/ix-application-header.js'; import { defineCustomElement as defineIxAvatar } from '@siemens/ix/components/ix-avatar.js'; +import { defineCustomElement as defineIxBadge } from '@siemens/ix/components/ix-badge.js'; import { defineCustomElement as defineIxBlind } from '@siemens/ix/components/ix-blind.js'; import { defineCustomElement as defineIxBreadcrumb } from '@siemens/ix/components/ix-breadcrumb.js'; import { defineCustomElement as defineIxBreadcrumbItem } from '@siemens/ix/components/ix-breadcrumb-item.js'; @@ -220,6 +221,29 @@ export class IxAvatar { export declare interface IxAvatar extends Components.IxAvatar {} +@ProxyCmp({ + defineCustomElementFn: defineIxBadge, + inputs: ['alignLeft', 'ariaLabelIcon', 'background', 'badgeColor', 'border', 'enableAnimation', 'icon', 'label', 'offsetX', 'offsetY', 'outline', 'position', 'tooltipText', 'type', 'variant'] +}) +@Component({ + selector: 'ix-badge', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['alignLeft', 'ariaLabelIcon', 'background', 'badgeColor', 'border', 'enableAnimation', 'icon', 'label', 'offsetX', 'offsetY', 'outline', 'position', 'tooltipText', 'type', 'variant'], +}) +export class IxBadge { + protected el: HTMLIxBadgeElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxBadge extends Components.IxBadge {} + + @ProxyCmp({ defineCustomElementFn: defineIxBlind, inputs: ['collapsed', 'icon', 'label', 'sublabel', 'variant'] diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 2a62dbd0e69..5b8a529625f 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -9,6 +9,7 @@ import { ActionCardVariant } from "./components/action-card/action-card.types"; import { ThemeVariant } from "./components/utils/theme-switcher"; import { Breakpoint } from "./components/utils/breakpoints"; import { AppSwitchConfiguration } from "./components/utils/application-layout/context"; +import { BadgeAnatomyType, BadgePosition, BadgeVariant } from "./components/badge/badge.types"; import { BlindVariant } from "./components/blind/blind.types"; import { BreadcrumbClick } from "./components/breadcrumb/breadcrumb.types"; import { AnchorTarget } from "./components/button/button.interface"; @@ -65,6 +66,7 @@ export { ActionCardVariant } from "./components/action-card/action-card.types"; export { ThemeVariant } from "./components/utils/theme-switcher"; export { Breakpoint } from "./components/utils/breakpoints"; export { AppSwitchConfiguration } from "./components/utils/application-layout/context"; +export { BadgeAnatomyType, BadgePosition, BadgeVariant } from "./components/badge/badge.types"; export { BlindVariant } from "./components/blind/blind.types"; export { BreadcrumbClick } from "./components/breadcrumb/breadcrumb.types"; export { AnchorTarget } from "./components/button/button.interface"; @@ -278,6 +280,104 @@ export namespace Components { */ "username"?: string; } + /** + * Overlay indicator for counts, labels, dots, and status icons. + * **Attached** (default slot has content): the indicator is decorative. + * When `label` is set, that text is exposed on the anchor via `aria-describedby`. + * Host `role` / `aria-*` are discarded so the anchor owns the accessible name. + * **Standalone** (empty default slot): author `role` / `aria-*` stay on the host. + * For `dot` and `status-icon`, provide a host `aria-label` and a naming role + * (for example `role="img"`, or `role="status"` / `role="alert"` for a live region). + * Override max-width with `--ix-badge-max-width`. + * @since 5.2.0 + */ + interface IxBadge { + /** + * Left-align label content. Only has an effect when **type** is `label`. + * @since 5.2.0 + * @default false + */ + "alignLeft": boolean; + /** + * Accessible name for the leading icon. When unset, the icon is decorative if **label** provides visible text. Only has an effect when **type** is `label`. + * @since 5.2.0 + */ + "ariaLabelIcon"?: string; + /** + * Custom background or border color. Only has an effect when **variant** is `custom`. + * @since 5.2.0 + */ + "background"?: string; + /** + * Custom text color. Only has an effect when **variant** is `custom`. + * @since 5.2.0 + */ + "badgeColor"?: string; + /** + * Add a high-contrast border on filled badges. Ignored when **outline** is `true` or **type** is `status-icon`. + * @since 5.2.0 + * @default false + */ + "border": boolean; + /** + * Play the attention pulse animation. Override duration with `--ix-badge-animation-duration` (default `2s`). + * @since 5.2.0 + * @default false + */ + "enableAnimation": boolean; + /** + * Leading icon name. Only has an effect when **type** is `label`. + * @since 5.2.0 + */ + "icon"?: string; + /** + * Visible text or count. Required for `label` and `counter`. Omit for `dot` and `status-icon`. Counters accept integers only (decimals truncated); values above 99 render as `99+`. + * @since 5.2.0 + */ + "label"?: string; + /** + * Extra horizontal offset in pixels. Only has an effect when attached. Added to the type default. + * @since 5.2.0 + * @default 0 + */ + "offsetX": number; + /** + * Extra vertical offset in pixels. Only has an effect when attached. Added to the type default. + * @since 5.2.0 + * @default 0 + */ + "offsetY": number; + /** + * Show the badge in outline style. + * @since 5.2.0 + * @default false + */ + "outline": boolean; + /** + * Position relative to the slotted anchor. Only has an effect when attached. + * @since 5.2.0 + * @default 'top-after' + */ + "position": BadgePosition; + /** + * Display a tooltip when the badge is standalone. By default, no tooltip is displayed. Add the attribute to use the badge label (or host `aria-label`) as the tooltip, or pass a string for custom text. Ignored when the badge is attached to an anchor. + * @since 5.2.0 + * @default false + */ + "tooltipText": string | boolean; + /** + * Badge type (`counter`, `label`, `dot`, or `status-icon`). + * @since 5.2.0 + * @default 'counter' + */ + "type": BadgeAnatomyType; + /** + * Color variant. For `status-icon`, unsupported values fall back to `info`. Use `error` only with `status-icon` (other types map it to `alarm`). + * @since 5.2.0 + * @default 'primary' + */ + "variant": BadgeVariant; + } interface IxBlind { /** * Collapsed state @@ -5172,6 +5272,23 @@ declare global { prototype: HTMLIxAvatarElement; new (): HTMLIxAvatarElement; }; + /** + * Overlay indicator for counts, labels, dots, and status icons. + * **Attached** (default slot has content): the indicator is decorative. + * When `label` is set, that text is exposed on the anchor via `aria-describedby`. + * Host `role` / `aria-*` are discarded so the anchor owns the accessible name. + * **Standalone** (empty default slot): author `role` / `aria-*` stay on the host. + * For `dot` and `status-icon`, provide a host `aria-label` and a naming role + * (for example `role="img"`, or `role="status"` / `role="alert"` for a live region). + * Override max-width with `--ix-badge-max-width`. + * @since 5.2.0 + */ + interface HTMLIxBadgeElement extends Components.IxBadge, HTMLStencilElement { + } + var HTMLIxBadgeElement: { + prototype: HTMLIxBadgeElement; + new (): HTMLIxBadgeElement; + }; interface HTMLIxBlindElementEventMap { "collapsedChange": boolean; } @@ -6755,6 +6872,7 @@ declare global { "ix-application-header": HTMLIxApplicationHeaderElement; "ix-application-switch-modal": HTMLIxApplicationSwitchModalElement; "ix-avatar": HTMLIxAvatarElement; + "ix-badge": HTMLIxBadgeElement; "ix-blind": HTMLIxBlindElement; "ix-breadcrumb": HTMLIxBreadcrumbElement; "ix-breadcrumb-item": HTMLIxBreadcrumbItemElement; @@ -7044,6 +7162,104 @@ declare namespace LocalJSX { */ "username"?: string; } + /** + * Overlay indicator for counts, labels, dots, and status icons. + * **Attached** (default slot has content): the indicator is decorative. + * When `label` is set, that text is exposed on the anchor via `aria-describedby`. + * Host `role` / `aria-*` are discarded so the anchor owns the accessible name. + * **Standalone** (empty default slot): author `role` / `aria-*` stay on the host. + * For `dot` and `status-icon`, provide a host `aria-label` and a naming role + * (for example `role="img"`, or `role="status"` / `role="alert"` for a live region). + * Override max-width with `--ix-badge-max-width`. + * @since 5.2.0 + */ + interface IxBadge { + /** + * Left-align label content. Only has an effect when **type** is `label`. + * @since 5.2.0 + * @default false + */ + "alignLeft"?: boolean; + /** + * Accessible name for the leading icon. When unset, the icon is decorative if **label** provides visible text. Only has an effect when **type** is `label`. + * @since 5.2.0 + */ + "ariaLabelIcon"?: string; + /** + * Custom background or border color. Only has an effect when **variant** is `custom`. + * @since 5.2.0 + */ + "background"?: string; + /** + * Custom text color. Only has an effect when **variant** is `custom`. + * @since 5.2.0 + */ + "badgeColor"?: string; + /** + * Add a high-contrast border on filled badges. Ignored when **outline** is `true` or **type** is `status-icon`. + * @since 5.2.0 + * @default false + */ + "border"?: boolean; + /** + * Play the attention pulse animation. Override duration with `--ix-badge-animation-duration` (default `2s`). + * @since 5.2.0 + * @default false + */ + "enableAnimation"?: boolean; + /** + * Leading icon name. Only has an effect when **type** is `label`. + * @since 5.2.0 + */ + "icon"?: string; + /** + * Visible text or count. Required for `label` and `counter`. Omit for `dot` and `status-icon`. Counters accept integers only (decimals truncated); values above 99 render as `99+`. + * @since 5.2.0 + */ + "label"?: string; + /** + * Extra horizontal offset in pixels. Only has an effect when attached. Added to the type default. + * @since 5.2.0 + * @default 0 + */ + "offsetX"?: number; + /** + * Extra vertical offset in pixels. Only has an effect when attached. Added to the type default. + * @since 5.2.0 + * @default 0 + */ + "offsetY"?: number; + /** + * Show the badge in outline style. + * @since 5.2.0 + * @default false + */ + "outline"?: boolean; + /** + * Position relative to the slotted anchor. Only has an effect when attached. + * @since 5.2.0 + * @default 'top-after' + */ + "position"?: BadgePosition; + /** + * Display a tooltip when the badge is standalone. By default, no tooltip is displayed. Add the attribute to use the badge label (or host `aria-label`) as the tooltip, or pass a string for custom text. Ignored when the badge is attached to an anchor. + * @since 5.2.0 + * @default false + */ + "tooltipText"?: string | boolean; + /** + * Badge type (`counter`, `label`, `dot`, or `status-icon`). + * @since 5.2.0 + * @default 'counter' + */ + "type"?: BadgeAnatomyType; + /** + * Color variant. For `status-icon`, unsupported values fall back to `info`. Use `error` only with `status-icon` (other types map it to `alarm`). + * @since 5.2.0 + * @default 'primary' + */ + "variant"?: BadgeVariant; + } interface IxBlind { /** * Collapsed state @@ -12000,6 +12216,23 @@ declare namespace LocalJSX { "tooltipText": string; "ariaLabelTooltip": string; } + interface IxBadgeAttributes { + "type": BadgeAnatomyType; + "label": string; + "variant": BadgeVariant; + "outline": boolean; + "border": boolean; + "position": BadgePosition; + "offsetX": number; + "offsetY": number; + "enableAnimation": boolean; + "background": string; + "badgeColor": string; + "icon": string; + "ariaLabelIcon": string; + "alignLeft": boolean; + "tooltipText": string; + } interface IxBlindAttributes { "collapsed": boolean; "label": string; @@ -13047,6 +13280,7 @@ declare namespace LocalJSX { "ix-application-header": Omit & { [K in keyof IxApplicationHeader & keyof IxApplicationHeaderAttributes]?: IxApplicationHeader[K] } & { [K in keyof IxApplicationHeader & keyof IxApplicationHeaderAttributes as `attr:${K}`]?: IxApplicationHeaderAttributes[K] } & { [K in keyof IxApplicationHeader & keyof IxApplicationHeaderAttributes as `prop:${K}`]?: IxApplicationHeader[K] }; "ix-application-switch-modal": IxApplicationSwitchModal; "ix-avatar": Omit & { [K in keyof IxAvatar & keyof IxAvatarAttributes]?: IxAvatar[K] } & { [K in keyof IxAvatar & keyof IxAvatarAttributes as `attr:${K}`]?: IxAvatarAttributes[K] } & { [K in keyof IxAvatar & keyof IxAvatarAttributes as `prop:${K}`]?: IxAvatar[K] }; + "ix-badge": Omit & { [K in keyof IxBadge & keyof IxBadgeAttributes]?: IxBadge[K] } & { [K in keyof IxBadge & keyof IxBadgeAttributes as `attr:${K}`]?: IxBadgeAttributes[K] } & { [K in keyof IxBadge & keyof IxBadgeAttributes as `prop:${K}`]?: IxBadge[K] }; "ix-blind": Omit & { [K in keyof IxBlind & keyof IxBlindAttributes]?: IxBlind[K] } & { [K in keyof IxBlind & keyof IxBlindAttributes as `attr:${K}`]?: IxBlindAttributes[K] } & { [K in keyof IxBlind & keyof IxBlindAttributes as `prop:${K}`]?: IxBlind[K] }; "ix-breadcrumb": Omit & { [K in keyof IxBreadcrumb & keyof IxBreadcrumbAttributes]?: IxBreadcrumb[K] } & { [K in keyof IxBreadcrumb & keyof IxBreadcrumbAttributes as `attr:${K}`]?: IxBreadcrumbAttributes[K] } & { [K in keyof IxBreadcrumb & keyof IxBreadcrumbAttributes as `prop:${K}`]?: IxBreadcrumb[K] }; "ix-breadcrumb-item": Omit & { [K in keyof IxBreadcrumbItem & keyof IxBreadcrumbItemAttributes]?: IxBreadcrumbItem[K] } & { [K in keyof IxBreadcrumbItem & keyof IxBreadcrumbItemAttributes as `attr:${K}`]?: IxBreadcrumbItemAttributes[K] } & { [K in keyof IxBreadcrumbItem & keyof IxBreadcrumbItemAttributes as `prop:${K}`]?: IxBreadcrumbItem[K] } & OneOf<"breadcrumbKey", IxBreadcrumbItem["breadcrumbKey"], IxBreadcrumbItemAttributes["breadcrumbKey"]>; @@ -13173,6 +13407,18 @@ declare module "@stencil/core" { "ix-application-header": LocalJSX.IntrinsicElements["ix-application-header"] & JSXBase.HTMLAttributes; "ix-application-switch-modal": LocalJSX.IntrinsicElements["ix-application-switch-modal"] & JSXBase.HTMLAttributes; "ix-avatar": LocalJSX.IntrinsicElements["ix-avatar"] & JSXBase.HTMLAttributes; + /** + * Overlay indicator for counts, labels, dots, and status icons. + * **Attached** (default slot has content): the indicator is decorative. + * When `label` is set, that text is exposed on the anchor via `aria-describedby`. + * Host `role` / `aria-*` are discarded so the anchor owns the accessible name. + * **Standalone** (empty default slot): author `role` / `aria-*` stay on the host. + * For `dot` and `status-icon`, provide a host `aria-label` and a naming role + * (for example `role="img"`, or `role="status"` / `role="alert"` for a live region). + * Override max-width with `--ix-badge-max-width`. + * @since 5.2.0 + */ + "ix-badge": LocalJSX.IntrinsicElements["ix-badge"] & JSXBase.HTMLAttributes; "ix-blind": LocalJSX.IntrinsicElements["ix-blind"] & JSXBase.HTMLAttributes; "ix-breadcrumb": LocalJSX.IntrinsicElements["ix-breadcrumb"] & JSXBase.HTMLAttributes; "ix-breadcrumb-item": LocalJSX.IntrinsicElements["ix-breadcrumb-item"] & JSXBase.HTMLAttributes; diff --git a/packages/core/src/components/badge/badge.scss b/packages/core/src/components/badge/badge.scss new file mode 100644 index 00000000000..c17fda9265c --- /dev/null +++ b/packages/core/src/components/badge/badge.scss @@ -0,0 +1,426 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@use 'mixins/shadow-dom/component'; +@use './badge.vars' as *; + +$predefined-variants: primary, alarm, critical, warning, info, neutral, success; +$status-icon-variants: alarm, critical, warning, info, success; +$border-radius: 100px; + +:host { + display: inline-block; + position: relative; + width: fit-content; + max-width: 100%; + vertical-align: top; + + @include component.ix-component; +} + +.anchor { + display: contents; +} + +:host(.attached) ::slotted(*) { + position: relative; + z-index: 0; +} + +.indicator { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border-radius: $border-radius; + font-size: 0.875rem; + line-height: 1.43; + letter-spacing: 0.012em; + max-width: var(--ix-badge-max-width); + z-index: 1; + pointer-events: none; + user-select: none; + white-space: nowrap; + isolation: isolate; +} + +/* Standalone text badges: allow selecting label/count text (usage guide). */ +:host([type='label']:not(.attached)) .indicator, +:host([type='counter']:not(.attached)) .indicator { + pointer-events: auto; + user-select: text; +} + +:host(.with-tooltip:not(.attached)) .indicator { + pointer-events: auto; +} + +.label { + position: relative; + z-index: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +:host(.attached) .indicator { + position: absolute; + z-index: 1; + pointer-events: auto; + user-select: none; + cursor: default; +} + +:host(.attached.top-after) .indicator { + top: var(--ix-badge-offset-y); + inset-inline-end: var(--ix-badge-offset-x); +} + +:host(.attached.bottom-after) .indicator { + bottom: var(--ix-badge-offset-y); + inset-inline-end: var(--ix-badge-offset-x); +} + +:host([type='dot']:not(.attached)), +:host([type='counter']:not(.attached)), +:host([type='label']:not(.attached)), +:host([type='status-icon']:not(.attached)) { + display: inline-flex; + line-height: 0; + font-size: 0; +} + +/* Standalone host uses font-size: 0 for sizing; restore text for nested tooltip. */ +:host ix-tooltip { + font-size: 0.875rem; + line-height: normal; +} + +:host([type='dot']:not(.attached)) slot, +:host([type='counter']:not(.attached)) slot, +:host([type='label']:not(.attached)) slot, +:host([type='status-icon']:not(.attached)) slot { + flex: 0 0 0; + width: 0; + height: 0; + overflow: hidden; +} + +:host([type='dot']) .indicator { + width: 0.75rem; + height: 0.75rem; + min-width: 0.75rem; + min-height: 0.75rem; + padding: 0; + line-height: 0; + font-size: 0; + flex-shrink: 0; +} + +:host([type='counter']) .indicator, +:host([type='label']) .indicator { + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + flex-shrink: 0; +} + +:host([type='counter']) .indicator { + padding-inline: 0.25rem; + font-weight: bold; + line-height: 1.25rem; +} + +:host([type='label']) .indicator { + padding-inline: 0.5rem; + gap: 0.25rem; + font-weight: 400; + line-height: 1.25rem; +} + +:host([type='label']:not(.attached)) .indicator { + width: 100%; + box-sizing: border-box; +} + +:host([type='label'].outline.with-icon) .indicator { + padding-inline: 0.4375rem; +} + +:host([type='label'].align-left) .indicator { + justify-content: flex-start; +} + +:host([type='label']) .icon { + flex-shrink: 0; +} + +:host([type='counter']) .label, +:host([type='label']) .label { + line-height: 1.25rem; +} + +:host([type='status-icon']) .indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + padding: 0; + line-height: 0; + font-size: 0; + flex-shrink: 0; + background-color: transparent; + border: none; +} + +/* Relative on the stack only — not on .indicator (breaks attached). */ +:host([type='status-icon']:not(.outline)) .status-icon-stack { + position: relative; + display: block; + width: 1.2rem; + height: 1.2rem; + flex-shrink: 0; +} + +:host([type='status-icon']) .status-icon { + flex-shrink: 0; + width: 1.2rem; + height: 1.2rem; + min-width: 1.2rem; + min-height: 1.2rem; +} + +:host([type='status-icon']:not(.outline)) .status-icon-stack .status-icon { + position: absolute; + inset: 0; +} + +@each $variant in $status-icon-variants { + :host([type='status-icon'].#{$variant}) { + --ix-badge-pulse-color: var(--theme-color-#{$variant}); + + .status-icon:not(.status-icon-plate) { + color: var(--theme-color-#{$variant}); + } + } +} + +/* Filled plate bang: black vs white (fixed — theme tokens flip by schema). */ +:host([type='status-icon'].critical:not(.outline)) .status-icon-plate, +:host([type='status-icon'].warning:not(.outline)) .status-icon-plate, +:host([type='status-icon'].success:not(.outline)) .status-icon-plate { + color: #000000; +} + +:host([type='status-icon'].alarm:not(.outline)) .status-icon-plate, +:host([type='status-icon'].error:not(.outline)) .status-icon-plate, +:host([type='status-icon'].info:not(.outline)) .status-icon-plate { + color: #ffffff; +} + +/* Error uses alarm color tokens. */ +:host([type='status-icon'].error) { + --ix-badge-pulse-color: var(--theme-color-alarm); + + .status-icon:not(.status-icon-plate) { + color: var(--theme-color-alarm); + } +} + +@each $variant in $predefined-variants { + :host(.#{$variant}:not(.outline):not([type='status-icon'])) { + --ix-badge-pulse-color: var(--theme-color-#{$variant}); + + .indicator { + background-color: var(--theme-color-#{$variant}); + color: var(--theme-color-#{$variant}--contrast); + } + } + + :host(.#{$variant}.outline:not([type='status-icon'])) { + --ix-badge-pulse-color: var(--theme-color-#{$variant}); + + .indicator { + background-color: var(--theme-color-ghost); + color: var(--theme-color-std-text); + @if $variant == 'alarm' { + border: 0.0625rem solid var(--theme-color-alarm-bdr); + } @else if $variant == 'warning' { + border: 0.0625rem solid var(--theme-color-warning-bdr); + } @else { + border: 0.0625rem solid var(--theme-color-#{$variant}); + } + } + } +} + +:host(.custom:not(.outline):not([type='status-icon'])) { + --ix-badge-pulse-color: var(--ix-badge-custom-background); + + .indicator { + background-color: var(--ix-badge-custom-background); + color: var(--ix-badge-custom-color); + } +} + +:host(.custom.outline:not([type='status-icon'])) { + --ix-badge-pulse-color: var(--ix-badge-custom-background); + + .indicator { + background-color: var(--theme-color-ghost); + border: 0.0625rem solid var(--ix-badge-custom-background); + color: var(--ix-badge-custom-color, var(--theme-color-std-text)); + } +} + +:host(.border:not(.outline):not([type='status-icon'])) .indicator { + outline: 0.0625rem solid var(--theme-color-1); + outline-offset: 0; +} + +/* SR-only description slot (light DOM for aria-describedby). */ +:host ::slotted(.description) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +:host(.enable-animation) .indicator { + overflow: visible; + position: relative; +} + +:host(.attached.enable-animation) .indicator { + position: absolute; +} + +/* status-icon pulse: solid halo from center. */ +:host([type='status-icon'].enable-animation) .indicator::after { + content: ''; + position: absolute; + inset: 0.025rem; + z-index: -1; + pointer-events: none; + border-radius: inherit; + background-color: var(--ix-badge-pulse-color); + transform: scale(0.521); + transform-origin: center; + opacity: 0; + animation: ix-badge-pulse-status var(--ix-badge-animation-duration) linear + infinite; +} + +/* counter / label / dot pulse: expanding border ring. */ +:host(.enable-animation:not([type='status-icon'])) .indicator::after { + content: ''; + position: absolute; + inset: 0.03125rem; /* 0.5px */ + z-index: -1; + pointer-events: none; + box-sizing: border-box; + background-color: transparent; + border-style: solid; + border-color: var(--ix-badge-pulse-color); + border-width: 0; + border-radius: inherit; + opacity: 0; + animation: ix-badge-pulse-border var(--ix-badge-animation-duration) linear + infinite; +} + +@media (prefers-reduced-motion: reduce) { + :host(.enable-animation) .indicator::after { + animation: none; + opacity: 0; + transform: none; + background-color: transparent; + border-width: 0; + } +} + +@keyframes ix-badge-pulse-border { + 0% { + inset: 0.03125rem; /* 0.5px */ + border-width: 0; + opacity: 0; + } + + 5% { + inset: 0.03125rem; + border-width: 0; + opacity: 0.5; + } + + 45% { + opacity: 0.4; + } + + 80% { + animation-timing-function: ease-out; + inset: -0.46875rem; /* -7.5px */ + border-width: 0.5rem; /* 8px */ + opacity: 0.1; + } + + 90% { + inset: -0.59375rem; /* -9.5px */ + border-width: 0.625rem; /* 10px */ + opacity: 0; + } + + 100% { + inset: 0.03125rem; + border-width: 0; + opacity: 0; + } +} + +@keyframes ix-badge-pulse-status { + 0% { + transform: scale(0.521); + opacity: 0; + } + + 5% { + transform: scale(0.521); + opacity: 0.5; + animation-timing-function: cubic-bezier(0.637, 0.43, 0.928, 1.008); + } + + 45% { + transform: scale(1); + opacity: 0.4; + } + + 80% { + animation-timing-function: ease-out; + transform: scale(1.6); + opacity: 0.1; + } + + 90% { + transform: scale(1.8); + opacity: 0; + } + + 100% { + transform: scale(0.521); + opacity: 0; + } +} diff --git a/packages/core/src/components/badge/badge.status-icon.ts b/packages/core/src/components/badge/badge.status-icon.ts new file mode 100644 index 00000000000..0d9c1b17997 --- /dev/null +++ b/packages/core/src/components/badge/badge.status-icon.ts @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + iconAlarm, + iconAlarmFilled, + iconCircleFilled, + iconError, + iconErrorFilled, + iconInfo, + iconInfoFilled, + iconRhombFilled, + iconSuccess, + iconSuccessFilled, + iconTriangleFilled, + iconWarning, + iconWarningFilled, + iconWarningRhomb, + iconWarningRhombFilled, +} from '@siemens/ix-icons/icons'; +import type { BadgeVariant } from './badge.types'; + +/** Variants valid for **type** `status-icon`. */ +export type BadgeStatusIconVariant = + | 'alarm' + | 'error' + | 'critical' + | 'warning' + | 'info' + | 'success'; + +type BadgeStatusIconPair = { + filled: string; + outline: string; + /** Solid plate under the filled glyph. */ + plate: string; +}; + +/** Fallback when the variant has no status glyph. */ +export const BADGE_STATUS_ICON_FALLBACK_VARIANT: BadgeStatusIconVariant = + 'info'; + +/** Status icons by variant (`outline` / `filled` / `plate`). */ +export const BADGE_STATUS_ICON_BY_VARIANT: Record< + BadgeStatusIconVariant, + BadgeStatusIconPair +> = { + alarm: { + filled: iconAlarmFilled, + outline: iconAlarm, + plate: iconCircleFilled, + }, + error: { + filled: iconErrorFilled, + outline: iconError, + plate: iconCircleFilled, + }, + critical: { + filled: iconWarningRhombFilled, + outline: iconWarningRhomb, + plate: iconRhombFilled, + }, + warning: { + filled: iconWarningFilled, + outline: iconWarning, + plate: iconTriangleFilled, + }, + info: { + filled: iconInfoFilled, + outline: iconInfo, + plate: iconCircleFilled, + }, + success: { + filled: iconSuccessFilled, + outline: iconSuccess, + plate: iconCircleFilled, + }, +}; + +export function isBadgeStatusIconVariant( + variant: string +): variant is BadgeStatusIconVariant { + return Object.hasOwn(BADGE_STATUS_ICON_BY_VARIANT, variant); +} + +function resolveStatusIconVariant( + variant: BadgeVariant +): BadgeStatusIconVariant { + return isBadgeStatusIconVariant(variant) + ? variant + : BADGE_STATUS_ICON_FALLBACK_VARIANT; +} + +/** Status glyph for a variant (falls back to **info**). */ +export function getBadgeStatusIcon( + variant: BadgeVariant, + outline = false +): string { + const icons = BADGE_STATUS_ICON_BY_VARIANT[resolveStatusIconVariant(variant)]; + return outline ? icons.outline : icons.filled; +} + +/** Plate icon for the filled status stack. */ +export function getBadgeStatusIconPlate(variant: BadgeVariant): string { + return BADGE_STATUS_ICON_BY_VARIANT[resolveStatusIconVariant(variant)].plate; +} + +/** Resolved status-icon variant for host classes (falls back to **info**). */ +export function getResolvedStatusIconVariant( + variant: BadgeVariant +): BadgeStatusIconVariant { + return resolveStatusIconVariant(variant); +} diff --git a/packages/core/src/components/badge/badge.tsx b/packages/core/src/components/badge/badge.tsx new file mode 100644 index 00000000000..d9d65d470e1 --- /dev/null +++ b/packages/core/src/components/badge/badge.tsx @@ -0,0 +1,650 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + Component, + Element, + h, + Host, + Mixin, + Prop, + State, + VNode, + Watch, +} from '@stencil/core'; +import { a11yBoolean, a11yHostAttributes } from '../utils/a11y'; +import { DefaultMixins } from '../utils/internal/component'; +import { + InheritAriaAttributesMixin, + InheritAriaAttributesMixinContract, +} from '../utils/internal/mixins/accessibility/inherit-aria-attributes.mixin'; +import { + ComponentIdMixin, + ComponentIdMixinContract, +} from '../utils/internal/mixins/id.mixin'; +import { makeRef } from '../utils/make-ref'; +import { convertToRemString } from '../utils/rwd.util'; +import { hasSlottedElements } from '../utils/shadow-dom'; +import { + getBadgeStatusIcon, + getBadgeStatusIconPlate, + getResolvedStatusIconVariant, +} from './badge.status-icon'; +import { + BADGE_ANATOMY_TYPES, + BADGE_ATTACHED_OFFSET_DEFAULTS, + BADGE_POSITIONS, + BADGE_VARIANTS, + BadgeAnatomyType, + BadgePosition, + BadgeVariant, +} from './badge.types'; +import { formatBadgeLabel } from './badge.utils'; + +/** Slot name for the screen-reader description. */ +const BADGE_DESCRIPTION_SLOT = 'description'; + +/** + * Overlay indicator for counts, labels, dots, and status icons. + * + * **Attached** (default slot has content): the indicator is decorative. + * When `label` is set, that text is exposed on the anchor via `aria-describedby`. + * Host `role` / `aria-*` are discarded so the anchor owns the accessible name. + * + * **Standalone** (empty default slot): author `role` / `aria-*` stay on the host. + * For `dot` and `status-icon`, provide a host `aria-label` and a naming role + * (for example `role="img"`, or `role="status"` / `role="alert"` for a live region). + * + * Override max-width with `--ix-badge-max-width`. + * + * @since 5.2.0 + */ +@Component({ + tag: 'ix-badge', + styleUrl: 'badge.scss', + shadow: true, +}) +export class Badge + extends Mixin(...DefaultMixins, InheritAriaAttributesMixin, ComponentIdMixin) + implements InheritAriaAttributesMixinContract, ComponentIdMixinContract +{ + @Element() override hostElement!: HTMLIxBadgeElement; + + /** + * Badge type (`counter`, `label`, `dot`, or `status-icon`). + * + * @since 5.2.0 + */ + @Prop({ reflect: true }) type: BadgeAnatomyType = 'counter'; + + /** + * Visible text or count. + * Required for `label` and `counter`. Omit for `dot` and `status-icon`. + * Counters accept integers only (decimals truncated); values above 99 render as `99+`. + * + * @since 5.2.0 + */ + @Prop({ reflect: true }) label?: string; + + /** + * Color variant. + * For `status-icon`, unsupported values fall back to `info`. + * Use `error` only with `status-icon` (other types map it to `alarm`). + * + * @since 5.2.0 + */ + @Prop({ reflect: true }) variant: BadgeVariant = 'primary'; + + /** + * Show the badge in outline style. + * + * @since 5.2.0 + */ + @Prop({ reflect: true }) outline = false; + + /** + * Add a high-contrast border on filled badges. + * Ignored when **outline** is `true` or **type** is `status-icon`. + * + * @since 5.2.0 + */ + @Prop({ reflect: true }) border = false; + + /** + * Position relative to the slotted anchor. + * Only has an effect when attached. + * + * @since 5.2.0 + */ + @Prop({ reflect: true }) position: BadgePosition = 'top-after'; + + /** + * Extra horizontal offset in pixels. + * Only has an effect when attached. + * Added to the type default. + * + * @since 5.2.0 + */ + @Prop({ attribute: 'offset-x' }) offsetX = 0; + + /** + * Extra vertical offset in pixels. + * Only has an effect when attached. + * Added to the type default. + * + * @since 5.2.0 + */ + @Prop({ attribute: 'offset-y' }) offsetY = 0; + + /** + * Play the attention pulse animation. + * Override duration with `--ix-badge-animation-duration` (default `2s`). + * + * @since 5.2.0 + */ + @Prop({ reflect: true }) enableAnimation = false; + + /** + * Custom background or border color. + * Only has an effect when **variant** is `custom`. + * + * @since 5.2.0 + */ + @Prop() background?: string; + + /** + * Custom text color. + * Only has an effect when **variant** is `custom`. + * + * @since 5.2.0 + */ + @Prop({ attribute: 'badge-color' }) badgeColor?: string; + + /** + * Leading icon name. + * Only has an effect when **type** is `label`. + * + * @since 5.2.0 + */ + @Prop() icon?: string; + + /** + * Accessible name for the leading icon. + * When unset, the icon is decorative if **label** provides visible text. + * Only has an effect when **type** is `label`. + * + * @since 5.2.0 + */ + @Prop({ attribute: 'aria-label-icon' }) ariaLabelIcon?: string; + + /** + * Left-align label content. + * Only has an effect when **type** is `label`. + * + * @since 5.2.0 + */ + @Prop({ attribute: 'align-left', reflect: true }) alignLeft = false; + + /** + * Display a tooltip when the badge is standalone. + * By default, no tooltip is displayed. + * Add the attribute to use the badge label (or host `aria-label`) as the tooltip, or pass a string for custom text. + * Ignored when the badge is attached to an anchor. + * + * @since 5.2.0 + */ + @Prop({ attribute: 'tooltip-text' }) tooltipText: string | boolean = false; + + @State() hasAnchor = false; + + private descriptionId = ''; + + private anchorElements: HTMLElement[] = []; + + private slotElement?: HTMLSlotElement; + + private readonly indicatorElementRef = makeRef(); + + private hasDisconnected = false; + + override componentWillLoad() { + // Mixin strips host ARIA into `inheritAriaAttributes` and removes them from the host. + super.componentWillLoad(); + this.descriptionId = `${this.getHostElementId()}-description`; + const hasAnchor = this.detectHasAnchor(); + + if (hasAnchor) { + this.hasAnchor = true; + // Attached: discard host ARIA — the anchor owns naming; the indicator is decorative + // (`aria-hidden`) and count/label text is exposed via `aria-describedby` on the anchor. + this.inheritAriaAttributes = {}; + return; + } + + this.hasAnchor = false; + // Standalone: keep mixin-captured ARIA and re-apply on `` in `render()`. + } + + override componentDidLoad() { + this.syncAnchorDescribedBy(); + } + + override componentDidRender() { + const nextHasAnchor = this.detectHasAnchor(); + + if (nextHasAnchor !== this.hasAnchor) { + this.applyAnchorMode(nextHasAnchor); + this.syncAnchorDescribedBy(); + } + } + + override connectedCallback() { + if (this.hasDisconnected) { + this.syncAnchorDescribedBy(); + this.hasDisconnected = false; + } + } + + override disconnectedCallback() { + this.clearAnchorDescribedBy(); + this.hasDisconnected = true; + } + + @Watch('label') + @Watch('type') + labelOrTypeChanged() { + if (this.hasAnchor) { + this.syncAnchorDescribedBy(); + } + } + + private readonly setSlotRef = (element: HTMLSlotElement | undefined) => { + this.slotElement = element; + }; + + private readonly onSlotChange = () => { + this.applyAnchorMode(this.detectHasAnchor()); + this.syncAnchorDescribedBy(); + }; + + private isDescriptionElement(element: Element): boolean { + return ( + element.getAttribute('slot') === BADGE_DESCRIPTION_SLOT || + (!!this.descriptionId && element.id === this.descriptionId) + ); + } + + private getAnchorElements(): HTMLElement[] { + return Array.from(this.hostElement.children).filter( + (child): child is HTMLElement => + child instanceof HTMLElement && !this.isDescriptionElement(child) + ); + } + + private detectHasAnchor(): boolean { + if (hasSlottedElements(this.slotElement)) { + return true; + } + + return this.getAnchorElements().length > 0; + } + + private applyAnchorMode(hasAnchor: boolean) { + if (hasAnchor === this.hasAnchor) { + return; + } + + this.clearAnchorDescribedBy(); + + if (hasAnchor) { + this.hasAnchor = true; + // Strip any ARIA still on the host (e.g. re-applied while standalone) and discard — + // attached mode must not keep role / aria-* on the wrapper. + a11yHostAttributes(this.hostElement); + this.inheritAriaAttributes = {}; + this.descriptionId = `${this.getHostElementId()}-description`; + return; + } + + this.hasAnchor = false; + // Leaving attached: capture any author ARIA set on the host for standalone ``. + this.inheritAriaAttributes = a11yHostAttributes(this.hostElement); + } + + private getResolvedVariant(): BadgeVariant { + if (this.variant === 'error') { + return this.getResolvedType() === 'status-icon' ? 'error' : 'alarm'; + } + + return (BADGE_VARIANTS as readonly string[]).includes(this.variant) + ? this.variant + : 'primary'; + } + + private getResolvedType(): BadgeAnatomyType { + return BADGE_ANATOMY_TYPES.includes(this.type) ? this.type : 'counter'; + } + + private getResolvedPosition(): BadgePosition { + return BADGE_POSITIONS.includes(this.position) + ? this.position + : 'top-after'; + } + + private getResolvedOffsets(): { x: number; y: number } { + const type = this.getResolvedType(); + const defaults = this.hasAnchor + ? BADGE_ATTACHED_OFFSET_DEFAULTS[type] + : { x: 0, y: 0 }; + + return { + x: defaults.x + this.offsetX, + y: defaults.y + this.offsetY, + }; + } + + private getFormattedLabel(type: BadgeAnatomyType): string | null { + return formatBadgeLabel(type, this.label); + } + + private getAccessibleText(formattedLabel: string | null): string | undefined { + return formattedLabel || undefined; + } + + private isTooltipRequested(): boolean { + return !!( + this.tooltipText || this.hostElement.hasAttribute('tooltip-text') + ); + } + + private hasVisibleIndicator( + type: BadgeAnatomyType, + formattedLabel: string | null + ): boolean { + if (type === 'counter' || type === 'label') { + return !!formattedLabel; + } + + return type === 'dot' || type === 'status-icon'; + } + + private shouldShowTooltip( + type: BadgeAnatomyType, + formattedLabel: string | null + ): boolean { + return ( + !this.hasAnchor && + this.isTooltipRequested() && + this.hasVisibleIndicator(type, formattedLabel) + ); + } + + private getTooltipContent(formattedLabel: string | null): string | undefined { + if (typeof this.tooltipText === 'string' && this.tooltipText.trim()) { + return this.tooltipText.trim(); + } + + return ( + formattedLabel?.trim() || + this.inheritAriaAttributes['aria-label']?.trim() || + this.hostElement.getAttribute('aria-label')?.trim() || + undefined + ); + } + + private getTooltip(formattedLabel: string | null) { + if (!this.shouldShowTooltip(this.getResolvedType(), formattedLabel)) { + return null; + } + + const text = this.getTooltipContent(formattedLabel); + if (!text) { + return null; + } + + return ( + + {text} + + ); + } + + private syncAnchorDescribedBy() { + this.clearAnchorDescribedBy(); + + if (!this.hasAnchor) { + return; + } + + const accessibleText = this.getAccessibleText( + this.getFormattedLabel(this.getResolvedType()) + ); + + if (!accessibleText) { + return; + } + + const descriptionEl = this.ensureLightDomDescription(accessibleText); + const anchors = this.getAnchorElements(); + + for (const anchor of anchors) { + const existing = anchor.getAttribute('aria-describedby'); + const ids = new Set( + existing?.split(/\s+/).filter((id) => id.length > 0) ?? [] + ); + ids.add(descriptionEl.id); + anchor.setAttribute('aria-describedby', Array.from(ids).join(' ')); + anchor.dataset.ixBadgeDescribedby = descriptionEl.id; + } + + this.anchorElements = anchors; + } + + private ensureLightDomDescription(text: string): HTMLElement { + let descriptionEl = Array.from(this.hostElement.children).find( + (child): child is HTMLElement => + child instanceof HTMLElement && this.isDescriptionElement(child) + ); + + if (!descriptionEl) { + descriptionEl = document.createElement('span'); + descriptionEl.slot = BADGE_DESCRIPTION_SLOT; + descriptionEl.className = 'description'; + this.hostElement.appendChild(descriptionEl); + } + + descriptionEl.id = this.descriptionId; + descriptionEl.textContent = text; + return descriptionEl; + } + + private removeLightDomDescription() { + Array.from(this.hostElement.children) + .filter( + (child): child is HTMLElement => + child instanceof HTMLElement && this.isDescriptionElement(child) + ) + .forEach((child) => child.remove()); + } + + private clearAnchorDescribedBy() { + for (const anchor of this.anchorElements) { + if (anchor.dataset.ixBadgeDescribedby !== this.descriptionId) { + continue; + } + + const existing = anchor.getAttribute('aria-describedby'); + const ids = + existing?.split(/\s+/).filter((id) => id !== this.descriptionId) ?? []; + + delete anchor.dataset.ixBadgeDescribedby; + + if (ids.length > 0) { + anchor.setAttribute('aria-describedby', ids.join(' ')); + } else { + anchor.removeAttribute('aria-describedby'); + } + } + + this.anchorElements = []; + this.removeLightDomDescription(); + } + + private renderIndicatorShell( + _accessibleText: string | undefined, + content?: VNode | VNode[] + ) { + return ( +
+ {content} +
+ ); + } + + private renderIndicator( + type: BadgeAnatomyType, + variant: BadgeVariant, + accessibleText: string | undefined, + formattedLabel: string | null + ) { + if (type === 'counter') { + if (!formattedLabel) { + return null; + } + + return this.renderIndicatorShell( + accessibleText, + {formattedLabel} + ); + } + + if (type === 'dot') { + return this.renderIndicatorShell(accessibleText); + } + + if (type === 'label') { + if (!formattedLabel) { + return null; + } + + const iconIsDecorative = !this.ariaLabelIcon?.trim(); + + return this.renderIndicatorShell(accessibleText, [ + this.icon ? ( + + ) : null, + + {formattedLabel} + , + ]); + } + + if (type === 'status-icon') { + if (this.outline) { + return this.renderIndicatorShell( + accessibleText, + + ); + } + + return this.renderIndicatorShell( + accessibleText, + + + + + ); + } + + return null; + } + + override render() { + const type = this.getResolvedType(); + const variant = this.getResolvedVariant(); + const position = this.getResolvedPosition(); + const offsets = this.getResolvedOffsets(); + const formattedLabel = this.getFormattedLabel(type); + const accessibleText = this.getAccessibleText(formattedLabel); + const hostVariant = + type === 'status-icon' ? getResolvedStatusIconVariant(variant) : variant; + const showBorder = this.border && type !== 'status-icon'; + const showTooltip = this.shouldShowTooltip(type, formattedLabel); + + const customHostStyle = + hostVariant === 'custom' + ? ({ + '--ix-badge-custom-background': this.background, + '--ix-badge-custom-color': this.badgeColor, + } as Record) + : undefined; + + return ( + + +
+ +
+ {this.renderIndicator(type, variant, accessibleText, formattedLabel)} + {this.getTooltip(formattedLabel)} +
+ ); + } +} diff --git a/packages/core/src/components/badge/badge.types.ts b/packages/core/src/components/badge/badge.types.ts new file mode 100644 index 00000000000..2a950a8e281 --- /dev/null +++ b/packages/core/src/components/badge/badge.types.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { ChipVariant } from '../chip/chip.types'; + +export const BADGE_ANATOMY_TYPES = [ + 'label', + 'counter', + 'dot', + 'status-icon', +] as const; + +export type BadgeAnatomyType = (typeof BADGE_ANATOMY_TYPES)[number]; + +/** Pill anatomies share chip semantic variants. */ +export { CHIP_VARIANTS as BADGE_VARIANTS } from '../chip/chip.types'; + +/** + * Badge variant. + * **error** is only for **type** `status-icon`; other anatomies coerce to `alarm`. + */ +export type BadgeVariant = ChipVariant | 'error'; + +export type { ChipVariant }; + +export const BADGE_POSITIONS = ['top-after', 'bottom-after'] as const; + +export type BadgePosition = (typeof BADGE_POSITIONS)[number]; + +export type BadgeAttachedOffset = { + x: number; + y: number; +}; + +/** Default attached offsets (px) added to **offsetX** / **offsetY**. */ +export const BADGE_ATTACHED_OFFSET_DEFAULTS: Record< + BadgeAnatomyType, + BadgeAttachedOffset +> = { + dot: { x: -6, y: -6 }, + label: { x: -10, y: -10 }, + counter: { x: -10, y: -10 }, + 'status-icon': { x: -10, y: -10 }, +}; diff --git a/packages/core/src/components/badge/badge.utils.ts b/packages/core/src/components/badge/badge.utils.ts new file mode 100644 index 00000000000..d829057e4ba --- /dev/null +++ b/packages/core/src/components/badge/badge.utils.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { BadgeAnatomyType } from './badge.types'; + +export const BADGE_OVERFLOW_THRESHOLD = 99; + +const INTEGER_LABEL_PATTERN = /^-?\d+(\.\d+)?$/; +const OVERFLOW_LABEL = `${BADGE_OVERFLOW_THRESHOLD}+`; + +type BadgeLabelInput = string | number | null; + +/** + * Formats badge label text for supported anatomy types. + * Returns `null` when the type has no text or the value is invalid. + * Counters truncate decimals and cap at `99+`. + */ +export function formatBadgeLabel( + type: BadgeAnatomyType, + label?: BadgeLabelInput +): string | null { + switch (type) { + case 'counter': + return formatCounterLabel(label); + case 'label': + return formatTextLabel(label); + case 'dot': + case 'status-icon': + return null; + } +} + +function coerceLabelText(label?: BadgeLabelInput): string { + if (label === undefined || label === null) { + return ''; + } + + return String(label).trim(); +} + +function formatTextLabel(label?: BadgeLabelInput): string | null { + const trimmed = coerceLabelText(label); + + if (!trimmed) { + return null; + } + + return trimmed; +} + +function formatCounterLabel(label?: BadgeLabelInput): string | null { + const trimmed = coerceLabelText(label); + + if (!trimmed) { + return null; + } + + if (trimmed === OVERFLOW_LABEL) { + return OVERFLOW_LABEL; + } + + if (!INTEGER_LABEL_PATTERN.test(trimmed)) { + return null; + } + + const intValue = Math.trunc(Number(trimmed)); + + if (intValue > BADGE_OVERFLOW_THRESHOLD) { + return OVERFLOW_LABEL; + } + + return String(intValue); +} diff --git a/packages/core/src/components/badge/badge.vars.scss b/packages/core/src/components/badge/badge.vars.scss new file mode 100644 index 00000000000..3e5aa6eacd1 --- /dev/null +++ b/packages/core/src/components/badge/badge.vars.scss @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +:host { + --ix-badge-offset-x: 0rem; + --ix-badge-offset-y: 0rem; + --ix-badge-max-width: none; + --ix-badge-pulse-color: var(--theme-color-primary); + --ix-badge-animation-duration: 2s; +} diff --git a/packages/core/src/components/badge/test/badge.ct.ts b/packages/core/src/components/badge/test/badge.ct.ts new file mode 100644 index 00000000000..e04e1753a11 --- /dev/null +++ b/packages/core/src/components/badge/test/badge.ct.ts @@ -0,0 +1,780 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { expect, Locator } from '@playwright/test'; +import { + iconCircleFilled, + iconInfo, + iconInfoFilled, + iconStar, +} from '@siemens/ix-icons/icons'; +import { regressionTest } from '@utils/test'; +import { BadgePage } from './badge.page'; + +const html = String.raw; + +async function expectVisibleTooltipText(tooltip: Locator, text: string) { + await expect(tooltip).toHaveClass(/visible/); + + const metrics = await tooltip.evaluate((el) => { + const slot = el.shadowRoot?.querySelector( + 'slot:not([name])' + ) as HTMLSlotElement | null; + const assigned = slot?.assignedNodes({ flatten: true }) ?? []; + const slottedText = assigned.map((node) => node.textContent ?? '').join(''); + let width = 0; + let height = 0; + + if (assigned[0]) { + const range = document.createRange(); + range.selectNodeContents(assigned[0]); + const rect = range.getBoundingClientRect(); + width = rect.width; + height = rect.height; + } + + return { + slottedText, + width, + height, + fontSize: getComputedStyle(el).fontSize, + }; + }); + + expect(metrics.slottedText).toBe(text); + expect(metrics.fontSize).toBe('14px'); + // Guard against host font-size:0 collapsing tooltip glyphs (empty bubble). + expect(metrics.width).toBeGreaterThan(8); + expect(metrics.height).toBeGreaterThan(8); +} + +regressionTest.describe('ix-badge', () => { + regressionTest.describe('rendering', () => { + regressionTest('hydrates standalone badge', async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + + await badge.expectHydrated(); + await badge.expectStandalone(); + await expect(badge.host).toHaveAttribute('variant', 'info'); + await badge.expectVisibleLabel('12'); + }); + + regressionTest( + 'hydrates attached badge with anchor class', + async ({ mount, page }) => { + await mount(html` + + Review changes + + `); + + const badge = new BadgePage(page); + await badge.expectHydrated(); + await badge.expectAttached(); + } + ); + + regressionTest('formats overflow label as 99+', async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectVisibleLabel('99+'); + }); + + regressionTest( + 'accepts preformatted overflow label 99+', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectVisibleLabel('99+'); + } + ); + + regressionTest( + 'renders no indicator when counter label is missing', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + + await badge.expectHydrated(); + await badge.expectNoIndicator(); + } + ); + + regressionTest( + 'renders no indicator for non-numeric counter label', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + + await badge.expectHydrated(); + await badge.expectNoIndicator(); + } + ); + + regressionTest( + 'renders standalone dot indicator without label text', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + + await badge.expectHydrated(); + await badge.expectIndicatorVisible(); + await expect(badge.label).toHaveCount(0); + } + ); + + regressionTest( + 'status-icon falls back to info for unsupported variants', + async ({ mount, page }) => { + await mount( + ``, + { icons: { iconInfoFilled, iconCircleFilled } } + ); + const badge = new BadgePage(page); + await badge.expectHostClass(/\binfo\b/); + await badge.expectIndicatorVisible(); + await expect(badge.statusIconGlyph).toBeVisible(); + await expect(badge.statusIconPlate).toBeVisible(); + } + ); + + regressionTest( + 'status-icon outline uses a different glyph than filled', + async ({ mount, page }) => { + await mount( + html` + + + `, + { icons: { iconInfo, iconInfoFilled, iconCircleFilled } } + ); + + const badge = new BadgePage(page); + const filled = badge.getByRole('img', 'Info filled'); + const outline = badge.getByRole('img', 'Info outline'); + + await expect(filled).toHaveClass(/\binfo\b/); + await expect(outline).toHaveClass(/\boutline\b/); + await expect( + badge.indicatorOf(filled).locator('ix-icon.status-icon-glyph') + ).toBeVisible(); + await expect( + badge.indicatorOf(filled).locator('ix-icon.status-icon-plate') + ).toBeVisible(); + await expect( + badge.indicatorOf(outline).locator('ix-icon.status-icon') + ).toBeVisible(); + await expect( + badge.indicatorOf(outline).locator('ix-icon.status-icon-plate') + ).toHaveCount(0); + + const filledName = await badge + .indicatorOf(filled) + .locator('ix-icon.status-icon-glyph') + .evaluate((el: HTMLIxIconElement) => el.name); + const outlineName = await badge + .indicatorOf(outline) + .locator('ix-icon.status-icon') + .evaluate((el: HTMLIxIconElement) => el.name); + + expect(filledName).toBeTruthy(); + expect(outlineName).toBeTruthy(); + expect(outlineName).not.toBe(filledName); + } + ); + }); + + regressionTest.describe('variants', () => { + regressionTest( + 'applies filled variant class on host', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectHostClass(/\balarm\b/); + } + ); + + regressionTest('applies outline class on host', async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectHostClass(/\boutline\b/); + }); + + regressionTest('applies border class on host', async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectHostClass(/\bborder\b/); + }); + + regressionTest( + 'does not apply border class for status-icon', + async ({ mount, page }) => { + await mount( + ``, + { icons: { iconInfoFilled, iconCircleFilled } } + ); + const badge = new BadgePage(page); + await badge.expectNoHostClass(/\bborder\b/); + } + ); + + regressionTest( + 'falls back to primary for unknown variant', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectHostClass(/\bprimary\b/); + } + ); + + regressionTest( + 'sets custom variant CSS variables on host', + async ({ mount, page }) => { + await mount( + `` + ); + + const badge = new BadgePage(page); + await badge.expectCustomVariantColors('red', 'white'); + } + ); + }); + + regressionTest.describe('position and animation', () => { + regressionTest( + 'defaults to top-after position class', + async ({ mount, page }) => { + await mount(html` + + Action + + `); + const badge = new BadgePage(page); + await badge.expectHostClass(/\btop-after\b/); + } + ); + + regressionTest( + 'applies bottom-after position class', + async ({ mount, page }) => { + await mount(html` + + Action + + `); + const badge = new BadgePage(page); + await badge.expectHostClass(/\bbottom-after\b/); + } + ); + + regressionTest( + 'ignores position class when standalone', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + + await badge.expectStandalone(); + await badge.expectNoHostClass(/\btop-after\b/); + await badge.expectNoHostClass(/\bbottom-after\b/); + } + ); + + regressionTest( + 'applies offset values as CSS custom properties', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + await badge.expectOffsets('-0.375rem', '-0.75rem'); + } + ); + + regressionTest( + 'applies dot anatomy default offsets when attached', + async ({ mount, page }) => { + await mount(html` + + + + `); + const badge = new BadgePage(page); + await badge.expectAttached(); + await badge.expectOffsets('-0.375rem', '-0.375rem'); + } + ); + + regressionTest( + 'applies enable-animation class on host', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectHostClass(/\benable-animation\b/); + } + ); + }); + + regressionTest.describe('attached accessibility', () => { + regressionTest( + 'discards host ARIA when attached (anchor owns naming)', + async ({ mount, page }) => { + await mount(html` + + + + `); + + const badge = new BadgePage(page); + await badge.expectAttached(); + await expect(badge.host).not.toHaveAttribute('role'); + await expect(badge.host).not.toHaveAttribute('aria-label'); + await badge.expectAttachedTextLabelA11y('Messages', '3'); + } + ); + + regressionTest( + 'sets aria-describedby for text label on anchor', + async ({ mount, page }) => { + await mount(html` + + Review changes + + `); + + const badge = new BadgePage(page); + await badge.expectAttachedTextLabelA11y('Review changes', '3'); + } + ); + + regressionTest( + 'does not set aria-describedby for dot badge on anchor', + async ({ mount, page }) => { + await mount( + html` + + + + `, + { icons: { iconInfo } } + ); + + const badge = new BadgePage(page); + await badge.expectDotAttachedNoDescribedBy('Notifications'); + } + ); + + regressionTest( + 'merges aria-describedby with existing anchor ids', + async ({ mount, page }) => { + await mount(html` + + + + Existing description + `); + + const badge = new BadgePage(page); + await badge.expectMergedDescribedBy('Inbox', 'existing-id', '5'); + } + ); + + regressionTest( + 'updates accessible description when label changes', + async ({ mount, page }) => { + await mount(html` + + + + `); + + const badge = new BadgePage(page); + await badge.expectAttachedTextLabelA11y('Messages', '3'); + + await badge.setLabel('8'); + + await badge.expectAttachedTextLabelA11y('Messages', '8'); + } + ); + + regressionTest( + 'removes accessible description from anchor on disconnect', + async ({ mount, page }) => { + await mount(html` + + + + `); + + const badge = new BadgePage(page); + const button = badge.getButton('Messages'); + await badge.expectAttachedTextLabelA11y('Messages', '3'); + + await badge.disconnectKeepingAnchor('Messages'); + + await expect(button).not.toHaveAttribute('aria-describedby'); + } + ); + + regressionTest( + 'restores accessible description after reconnect', + async ({ mount, page }) => { + await mount(html` +
+ + + +
+ `); + + const badge = new BadgePage(page); + await badge.expectAttachedTextLabelA11y('Messages', '3'); + + await page.evaluate(() => { + const host = document.querySelector('ix-badge'); + const mountPoint = document.getElementById('mount'); + if (host && mountPoint) { + host.remove(); + mountPoint.appendChild(host); + } + }); + + await badge.expectAttachedTextLabelA11y('Messages', '3'); + } + ); + }); + + regressionTest.describe('label type', () => { + regressionTest('renders standalone label text', async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + + await expect(badge.host).toHaveAttribute('type', 'label'); + await badge.expectVisibleLabel('NEW'); + }); + + regressionTest( + 'allows selecting standalone label text', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + await badge.expectHydrated(); + + await expect(badge.indicator).toHaveCSS('user-select', 'text'); + } + ); + + regressionTest( + 'keeps attached label text non-selectable', + async ({ mount, page }) => { + await mount(html` + + + + `); + const badge = new BadgePage(page); + await badge.expectAttached(); + + await expect(badge.indicator).toHaveCSS('user-select', 'none'); + } + ); + + regressionTest( + 'renders no indicator for empty label text', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + + await badge.expectHydrated(); + await badge.expectNoIndicator(); + } + ); + + regressionTest( + 'renders label with icon and align-left class', + async ({ mount, page }) => { + await mount( + ``, + { icons: { iconStar } } + ); + const badge = new BadgePage(page); + + await badge.expectHostClass(/\balign-left\b/); + await badge.expectHostClass(/\bwith-icon\b/); + await badge.expectLeadingIconDecorative(); + } + ); + + regressionTest( + 'names leading icon when aria-label-icon is set', + async ({ mount, page }) => { + await mount( + ``, + { icons: { iconStar } } + ); + const badge = new BadgePage(page); + + await badge.expectLeadingIconNamed('Featured'); + await badge.expectVisibleLabel('NEW'); + } + ); + + regressionTest( + 'sets aria-describedby for attached label on anchor', + async ({ mount, page }) => { + await mount(html` + + What's new + + `); + + const badge = new BadgePage(page); + await badge.expectAttachedTextLabelA11y("What's new", 'NEW'); + } + ); + }); + + regressionTest.describe('standalone accessibility', () => { + regressionTest( + 'standalone dot host matches 12×12 indicator size', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectHydrated(); + await badge.expectStandalone(); + + await expect(async () => { + const hostBox = await badge.host.boundingBox(); + const indicatorBox = await badge.indicator.boundingBox(); + + expect(hostBox?.width).toBeCloseTo(12, 0); + expect(hostBox?.height).toBeCloseTo(12, 0); + expect(indicatorBox?.width).toBeCloseTo(12, 0); + expect(indicatorBox?.height).toBeCloseTo(12, 0); + }).toPass(); + } + ); + + regressionTest( + 'standalone label host matches indicator size', + async ({ mount, page }) => { + await mount( + ``, + { icons: { iconStar } } + ); + const badge = new BadgePage(page); + await badge.expectHydrated(); + await badge.expectStandalone(); + await expect(badge.indicator.locator('ix-icon')).toBeVisible(); + + await expect(async () => { + const hostBox = await badge.host.boundingBox(); + const indicatorBox = await badge.indicator.boundingBox(); + + expect(hostBox?.height).toBeCloseTo(20, 0); + expect(indicatorBox?.height).toBeCloseTo(20, 0); + expect(hostBox?.width).toBeCloseTo(indicatorBox?.width ?? 0, 0); + }).toPass(); + } + ); + + regressionTest( + 'keeps standalone chrome static without default live region', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectStandaloneStaticChrome({ + visibleLabel: '12', + hostRole: null, + }); + } + ); + + regressionTest( + 'keeps author role and aria-label on the host for standalone', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + await badge.expectStandaloneHostAria({ + role: 'alert', + ariaLabel: 'Urgent update', + }); + await expect(badge.getByRole('alert', 'Urgent update')).toBeVisible(); + } + ); + + regressionTest( + 'keeps opt-in status live region attributes on the host', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + await badge.expectStandaloneHostAria({ + role: 'status', + ariaLabel: 'Cart count', + ariaLive: 'polite', + }); + } + ); + }); + + regressionTest.describe('tooltip', () => { + regressionTest( + 'does not render tooltip when tooltip-text is absent', + async ({ mount, page }) => { + await mount(``); + const badge = new BadgePage(page); + await badge.expectHydrated(); + await badge.host.hover(); + + await expect(badge.host).not.toHaveAttribute('tooltip-text'); + await expect(badge.tooltip()).not.toBeAttached(); + } + ); + + regressionTest( + 'shows label text when tooltip-text attribute is present', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + await badge.expectHydrated(); + await badge.indicator.hover(); + + await expectVisibleTooltipText(badge.tooltip(), 'Label'); + } + ); + + regressionTest( + 'shows custom tooltip text for standalone counter', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + await badge.expectHydrated(); + await badge.indicator.hover(); + + await expectVisibleTooltipText(badge.tooltip(), 'Twelve items'); + } + ); + + regressionTest( + 'uses aria-label as tooltip text for standalone dot', + async ({ mount, page }) => { + await mount( + `` + ); + const badge = new BadgePage(page); + const named = badge.getByRole('img', 'Unread'); + await badge.expectHydrated(named); + await badge.indicatorOf(named).hover(); + + await expectVisibleTooltipText(badge.tooltip(named), 'Unread'); + } + ); + + regressionTest( + 'ignores tooltip-text when badge is attached', + async ({ mount, page }) => { + await mount(html` + + Review + + `); + const badge = new BadgePage(page); + await badge.expectHydrated(); + await badge.expectAttached(); + await badge.indicator.hover(); + + await expect(badge.tooltip()).not.toBeAttached(); + } + ); + }); + + regressionTest('accessibility', async ({ mount, makeAxeBuilder, page }) => { + await mount(html` +
+ + Review changes + + + + + + +
+ `); + + // Nested tooltips must expose `role="tooltip"` before axe; otherwise + // `aria-label` on `ix-tooltip` trips aria-prohibited-attr. Slot text alone is + // not enough: content is projected into an inert dialog until shown. + await expect(page.getByRole('tooltip')).toHaveCount(2); + + const accessibilityScanResults = await makeAxeBuilder().analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + }); +}); diff --git a/packages/core/src/components/badge/test/badge.page.ts b/packages/core/src/components/badge/test/badge.page.ts new file mode 100644 index 00000000000..872e3f26f64 --- /dev/null +++ b/packages/core/src/components/badge/test/badge.page.ts @@ -0,0 +1,282 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { expect, Locator, Page } from '@playwright/test'; + +/** + * Page Object Model for ix-badge component testing. + * + * Prefer accessibility locators (`getByRole`, `getByText`, accessible name / + * description) for author-facing contracts. Fall back to tag / CSS selectors + * for custom-element host state and decorative chrome (e.g. attached + * `aria-hidden` indicator) that is intentionally outside the a11y tree. + * + * Usage: + * ```ts + * const badge = new BadgePage(page); + * await badge.expectHydrated(); + * await badge.expectVisibleLabel('12'); + * ``` + */ +export class BadgePage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** First badge on the page (single-badge fixtures without a naming role). */ + get host(): Locator { + return this.page.locator('ix-badge').first(); + } + + /** + * Named standalone badge via host `role` + accessible name. + * Prefer this over `host` when the fixture sets `role` / `aria-label`. + */ + getByRole(role: 'img' | 'alert' | 'status', name: string | RegExp): Locator { + return this.page.getByRole(role, { name }); + } + + getButton(name: string | RegExp): Locator { + return this.page.getByRole('button', { name }); + } + + /** + * Decorative indicator chrome (often `aria-hidden` when attached). + * Structural on purpose — not exposed in the accessibility tree. + */ + get indicator(): Locator { + return this.host.locator('.indicator'); + } + + indicatorOf(badge: Locator): Locator { + return badge.locator('.indicator'); + } + + get label(): Locator { + return this.host.locator('.label'); + } + + get description(): Locator { + return this.host.locator(`> .description, > [slot="description"]`).first(); + } + + get leadingIcon(): Locator { + return this.indicator.locator('ix-icon.icon'); + } + + get statusIconPlate(): Locator { + return this.indicator.locator('ix-icon.status-icon-plate'); + } + + get statusIconGlyph(): Locator { + return this.indicator.locator('ix-icon.status-icon-glyph'); + } + + async expectHydrated(badge: Locator = this.host): Promise { + await expect(badge).toHaveClass(/\bhydrated\b/); + } + + async expectAttached(badge: Locator = this.host): Promise { + await expect(badge).toHaveClass(/\battached\b/); + } + + async expectStandalone(badge: Locator = this.host): Promise { + await expect(badge).not.toHaveClass(/\battached\b/); + } + + async expectHostClass( + pattern: RegExp, + badge: Locator = this.host + ): Promise { + await expect(badge).toHaveClass(pattern); + } + + async expectNoHostClass( + pattern: RegExp, + badge: Locator = this.host + ): Promise { + await expect(badge).not.toHaveClass(pattern); + } + + async expectVisibleLabel( + text: string, + badge: Locator = this.host + ): Promise { + await expect(badge.getByText(text, { exact: true })).toBeVisible(); + } + + async expectNoIndicator(badge: Locator = this.host): Promise { + await expect(this.indicatorOf(badge)).toHaveCount(0); + } + + async expectIndicatorVisible(badge: Locator = this.host): Promise { + await expect(this.indicatorOf(badge)).toBeVisible(); + } + + async expectLeadingIconDecorative(): Promise { + await expect(this.leadingIcon).toBeVisible(); + await expect(this.leadingIcon).toHaveAttribute('aria-hidden', 'true'); + } + + async expectLeadingIconNamed(ariaLabel: string): Promise { + await expect(this.indicator.getByLabel(ariaLabel)).toBeVisible(); + await expect(this.leadingIcon).toHaveAttribute('aria-hidden', 'false'); + } + + async expectCustomVariantColors( + background: string, + color: string, + badge: Locator = this.host + ): Promise { + await expect(async () => { + const resolvedBackground = await badge.evaluate((el) => + el.style.getPropertyValue('--ix-badge-custom-background') + ); + const resolvedColor = await badge.evaluate((el) => + el.style.getPropertyValue('--ix-badge-custom-color') + ); + expect(resolvedBackground).toBe(background); + expect(resolvedColor).toBe(color); + }).toPass(); + } + + async expectOffsets( + offsetX: string, + offsetY: string, + badge: Locator = this.host + ): Promise { + await expect(async () => { + const resolvedOffsetX = await badge.evaluate((el) => + el.style.getPropertyValue('--ix-badge-offset-x') + ); + const resolvedOffsetY = await badge.evaluate((el) => + el.style.getPropertyValue('--ix-badge-offset-y') + ); + expect(resolvedOffsetX).toBe(offsetX); + expect(resolvedOffsetY).toBe(offsetY); + }).toPass(); + } + + async expectAttachedTextLabelA11y( + buttonName: string, + descriptionText: string + ): Promise { + const button = this.getButton(buttonName); + const describedBy = await button.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + + await expect(this.description).toHaveText(descriptionText); + const descriptionId = await this.description.getAttribute('id'); + expect(descriptionId).toBeTruthy(); + expect(describedBy!.split(/\s+/)).toContain(descriptionId); + + // Description stays in light DOM for aria-describedby (SR-only chrome is + // not always exposed via Playwright's accessible description API). + await expect + .poll(async () => + this.page.evaluate( + (id) => document.getElementById(id)?.textContent ?? null, + descriptionId! + ) + ) + .toBe(descriptionText); + await expect(this.indicator).toHaveAttribute('aria-hidden', 'true'); + } + + async expectDotAttachedNoDescribedBy(buttonName: string): Promise { + const button = this.getButton(buttonName); + await expect(button).not.toHaveAttribute('aria-describedby'); + await expect(this.host).toHaveAttribute('type', 'dot'); + await expect(this.description).toHaveCount(0); + } + + async expectMergedDescribedBy( + buttonName: string, + existingId: string, + descriptionText: string + ): Promise { + const button = this.getButton(buttonName); + const describedBy = await button.getAttribute('aria-describedby'); + + expect(describedBy).toContain(existingId); + expect(describedBy?.split(/\s+/).length).toBeGreaterThan(1); + await expect(this.description).toHaveText(descriptionText); + } + + async expectStandaloneStaticChrome( + options: { + visibleLabel?: string; + hostRole?: string | null; + hostAriaLabel?: string; + } = {} + ): Promise { + await this.expectStandalone(); + await expect(this.indicator).not.toHaveAttribute('role'); + await expect(this.indicator).not.toHaveAttribute('aria-live'); + await expect(this.indicator).not.toHaveAttribute('aria-label'); + + if (options.visibleLabel !== undefined) { + await this.expectVisibleLabel(options.visibleLabel); + } + + if (options.hostRole === null) { + await expect(this.host).not.toHaveAttribute('role'); + } else if (options.hostRole !== undefined) { + await expect(this.host).toHaveAttribute('role', options.hostRole); + } + + if (options.hostAriaLabel !== undefined) { + await expect(this.host).toHaveAttribute( + 'aria-label', + options.hostAriaLabel + ); + } + } + + async expectStandaloneHostAria(options: { + role: 'img' | 'alert' | 'status'; + ariaLabel: string; + ariaLive?: string; + }): Promise { + const named = this.getByRole(options.role, options.ariaLabel); + await expect(named).toBeVisible(); + await this.expectStandalone(named); + await expect(this.indicatorOf(named)).not.toHaveAttribute('role'); + + if (options.ariaLive !== undefined) { + await expect(named).toHaveAttribute('aria-live', options.ariaLive); + } + } + + async setLabel(label: string, badge: Locator = this.host): Promise { + await badge.evaluate( + (el: HTMLElement & { label?: string }, value: string) => { + el.label = value; + }, + label + ); + } + + async disconnectKeepingAnchor(buttonName: string): Promise { + const button = this.getButton(buttonName); + await button.evaluate((anchor) => { + const badge = anchor.closest('ix-badge'); + if (badge) { + document.body.appendChild(anchor); + badge.remove(); + } + }); + } + + tooltip(badge: Locator = this.host): Locator { + return badge.locator('ix-tooltip'); + } +} diff --git a/packages/core/src/components/badge/test/badge.spec.ts b/packages/core/src/components/badge/test/badge.spec.ts new file mode 100644 index 00000000000..00181443839 --- /dev/null +++ b/packages/core/src/components/badge/test/badge.spec.ts @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + iconAlarm, + iconAlarmFilled, + iconCircleFilled, + iconError, + iconErrorFilled, + iconInfo, + iconInfoFilled, + iconRhombFilled, + iconSuccess, + iconSuccessFilled, + iconTriangleFilled, + iconWarning, + iconWarningFilled, + iconWarningRhomb, + iconWarningRhombFilled, +} from '@siemens/ix-icons/icons'; +import { describe, expect, it } from 'vitest'; +import { + BADGE_STATUS_ICON_BY_VARIANT, + BADGE_STATUS_ICON_FALLBACK_VARIANT, + getBadgeStatusIcon, + getBadgeStatusIconPlate, + getResolvedStatusIconVariant, + isBadgeStatusIconVariant, +} from '../badge.status-icon'; +import type { BadgeVariant } from '../badge.types'; +import { formatBadgeLabel } from '../badge.utils'; + +describe('formatBadgeLabel', () => { + it.each([ + ['dot', undefined], + ['dot', '3'], + ['status-icon', undefined], + ['status-icon', 'ok'], + ] as const)( + 'returns null for anatomy type %s with label %j', + (type, label) => { + expect(formatBadgeLabel(type, label)).toBeNull(); + } + ); + + it.each([ + ['3', '3'], + [3, '3'], + [100, '99+'], + ['99', '99'], + ['100', '99+'], + ['99+', '99+'], + ['3.7', '3'], + ['-1', '-1'], + [' 5 ', '5'], + ['new', null], + [undefined, null], + ['', null], + [' ', null], + ] as const)('formats counter label %j as %j', (label, expected) => { + expect(formatBadgeLabel('counter', label)).toBe(expected); + }); + + it.each([ + ['NEW', 'NEW'], + [' Beta ', 'Beta'], + [42, '42'], + [' ', null], + [undefined, null], + ] as const)('formats label text %j as %j', (label, expected) => { + expect(formatBadgeLabel('label', label)).toBe(expected); + }); +}); + +describe('badge.status-icon', () => { + const unsupportedVariants = ['primary', 'neutral', 'custom'] as const; + + it.each([ + ['alarm', iconAlarmFilled, iconAlarm, iconCircleFilled], + ['error', iconErrorFilled, iconError, iconCircleFilled], + ['critical', iconWarningRhombFilled, iconWarningRhomb, iconRhombFilled], + ['warning', iconWarningFilled, iconWarning, iconTriangleFilled], + ['info', iconInfoFilled, iconInfo, iconCircleFilled], + ['success', iconSuccessFilled, iconSuccess, iconCircleFilled], + ] as const)( + 'maps %s to filled, outline, and plate glyphs', + (variant, filled, outline, plate) => { + expect(BADGE_STATUS_ICON_BY_VARIANT[variant]).toEqual({ + filled, + outline, + plate, + }); + expect(getBadgeStatusIcon(variant, false)).toBe(filled); + expect(getBadgeStatusIcon(variant, true)).toBe(outline); + expect(getBadgeStatusIconPlate(variant)).toBe(plate); + expect(outline).not.toBe(filled); + } + ); + + it.each([ + ['alarm', true], + ['error', true], + ['critical', true], + ['warning', true], + ['info', true], + ['success', true], + ['primary', false], + ['neutral', false], + ['custom', false], + ] as const)('isBadgeStatusIconVariant(%s) → %s', (variant, expected) => { + expect(isBadgeStatusIconVariant(variant)).toBe(expected); + }); + + it.each([ + ['alarm', 'alarm'], + ['error', 'error'], + ['critical', 'critical'], + ['warning', 'warning'], + ['info', 'info'], + ['success', 'success'], + ['primary', 'info'], + ['neutral', 'info'], + ['custom', 'info'], + ] as const)('getResolvedStatusIconVariant(%s) → %s', (variant, expected) => { + expect(getResolvedStatusIconVariant(variant)).toBe(expected); + }); + + it('uses info as the unsupported-variant fallback constant', () => { + expect(BADGE_STATUS_ICON_FALLBACK_VARIANT).toBe('info'); + }); + + it('defaults to the filled glyph when outline is omitted', () => { + expect(getBadgeStatusIcon('info')).toBe(iconInfoFilled); + }); + + it.each(unsupportedVariants)( + 'falls back to info glyphs for unsupported variant %s', + (variant) => { + expect(getBadgeStatusIcon(variant)).toBe(iconInfoFilled); + expect(getBadgeStatusIcon(variant, true)).toBe(iconInfo); + expect(getBadgeStatusIconPlate(variant)).toBe(iconCircleFilled); + } + ); + + it('treats unknown variant strings like unsupported chip variants', () => { + const unknown = 'not-a-variant' as BadgeVariant; + + expect(isBadgeStatusIconVariant(unknown)).toBe(false); + expect(getResolvedStatusIconVariant(unknown)).toBe('info'); + expect(getBadgeStatusIcon(unknown)).toBe(iconInfoFilled); + expect(getBadgeStatusIcon(unknown, true)).toBe(iconInfo); + expect(getBadgeStatusIconPlate(unknown)).toBe(iconCircleFilled); + }); +}); diff --git a/packages/core/src/components/menu-category/test/menu-category.ct.ts b/packages/core/src/components/menu-category/test/menu-category.ct.ts index 63dac55dcd1..e2392612c1f 100644 --- a/packages/core/src/components/menu-category/test/menu-category.ct.ts +++ b/packages/core/src/components/menu-category/test/menu-category.ct.ts @@ -491,8 +491,10 @@ regressionTest( const menuItems = categoryElement.locator('.menu-items'); await expect(menuItems).toHaveClass(/menu-items--expanded/); + // Wait for hydration before programmatic focus — otherwise focus is lost when + // Stencil replaces the light DOM / attaches the shadow button (delegatesFocus). + await expect(categoryButton).toHaveClass(/hydrated/); await categoryButton.focus(); - await expect(categoryButton).toBeFocused(); // Press ArrowDown should move focus to the first nested item diff --git a/packages/html-test-app/src/preview-examples/badge-counter.css b/packages/html-test-app/src/preview-examples/badge-counter.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-counter.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/html-test-app/src/preview-examples/badge-counter.html b/packages/html-test-app/src/preview-examples/badge-counter.html new file mode 100644 index 00000000000..e635b2f5b09 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-counter.html @@ -0,0 +1,63 @@ + + + + + + + + Badge counter + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/packages/html-test-app/src/preview-examples/badge-dot.css b/packages/html-test-app/src/preview-examples/badge-dot.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-dot.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/html-test-app/src/preview-examples/badge-dot.html b/packages/html-test-app/src/preview-examples/badge-dot.html new file mode 100644 index 00000000000..3a8166a15f8 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-dot.html @@ -0,0 +1,63 @@ + + + + + + + + Badge dot + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/packages/html-test-app/src/preview-examples/badge-label.css b/packages/html-test-app/src/preview-examples/badge-label.css new file mode 100644 index 00000000000..e8543b9428b --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-label.css @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} diff --git a/packages/html-test-app/src/preview-examples/badge-label.html b/packages/html-test-app/src/preview-examples/badge-label.html new file mode 100644 index 00000000000..d98fdb501b9 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-label.html @@ -0,0 +1,60 @@ + + + + + + + + Badge label + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/packages/html-test-app/src/preview-examples/badge-status-icon.css b/packages/html-test-app/src/preview-examples/badge-status-icon.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-status-icon.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/html-test-app/src/preview-examples/badge-status-icon.html b/packages/html-test-app/src/preview-examples/badge-status-icon.html new file mode 100644 index 00000000000..14a98110f43 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge-status-icon.html @@ -0,0 +1,53 @@ + + + + + + + + Badge status icon + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/packages/html-test-app/src/preview-examples/badge.css b/packages/html-test-app/src/preview-examples/badge.css new file mode 100644 index 00000000000..875f0b6238a --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge.css @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2rem; + margin-bottom: 1.5rem; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1.5rem; +} + +.styled { + width: 7rem; + --ix-badge-max-width: 7rem; +} + +.styled-ellipsis-4 { + width: 4rem; + --ix-badge-max-width: 4rem; +} diff --git a/packages/html-test-app/src/preview-examples/badge.html b/packages/html-test-app/src/preview-examples/badge.html new file mode 100644 index 00000000000..fcf38c17d0c --- /dev/null +++ b/packages/html-test-app/src/preview-examples/badge.html @@ -0,0 +1,57 @@ + + + + + + + + Badge example + + + + +
+ Review + + Bottom after + Messages + Device +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + diff --git a/packages/react-test-app/src/main.tsx b/packages/react-test-app/src/main.tsx index 1e2e45e96e3..e1da8f0e39d 100644 --- a/packages/react-test-app/src/main.tsx +++ b/packages/react-test-app/src/main.tsx @@ -28,6 +28,11 @@ import ApplicationHeader from './preview-examples/application-header'; import Avatar from './preview-examples/avatar'; import AvatarImage from './preview-examples/avatar-image'; import AvatarInitials from './preview-examples/avatar-initials'; +import Badge from './preview-examples/badge'; +import BadgeDot from './preview-examples/badge-dot'; +import BadgeLabel from './preview-examples/badge-label'; +import BadgeStatusIcon from './preview-examples/badge-status-icon'; +import BadgeCounter from './preview-examples/badge-counter'; import Blind from './preview-examples/blind'; import BlindHeaderActions from './preview-examples/blind-header-actions'; import BlindVariants from './preview-examples/blind-variants'; @@ -67,8 +72,6 @@ import CustomField from './preview-examples/custom-field'; import CustomFieldValidation from './preview-examples/custom-field-validation'; import DateDropdown from './preview-examples/date-dropdown'; import DateDropdownPresets from './preview-examples/date-dropdown-presets'; -import DateRange from './preview-examples/date-range'; -import DatetimeRange from './preview-examples/datetime-range'; import DateInput from './preview-examples/date-input'; import dateInputDisabled from './preview-examples/date-input-disabled.tsx'; import dateInputLabel from './preview-examples/date-input-label.tsx'; @@ -76,7 +79,10 @@ import dateInputMinMaxDate from './preview-examples/date-input-min-max-date.tsx' import dateInputReadonly from './preview-examples/date-input-readonly.tsx'; import dateInputValidation from './preview-examples/date-input-validation.tsx'; import DateInputWithSlots from './preview-examples/date-input-with-slots'; +import DateRange from './preview-examples/date-range'; import Datepicker from './preview-examples/datepicker'; +import DatepickerLocale from './preview-examples/datepicker-locale'; +import DatepickerRange from './preview-examples/datepicker-range'; import DatetimeInput from './preview-examples/datetime-input'; import DatetimeInputDisabled from './preview-examples/datetime-input-disabled'; import DatetimeInputLabel from './preview-examples/datetime-input-label'; @@ -84,8 +90,7 @@ import DatetimeInputMinMaxDate from './preview-examples/datetime-input-min-max-d import DatetimeInputReadonly from './preview-examples/datetime-input-readonly'; import DatetimeInputValidation from './preview-examples/datetime-input-validation'; import DatetimeInputWithSlots from './preview-examples/datetime-input-with-slots'; -import DatepickerLocale from './preview-examples/datepicker-locale'; -import DatepickerRange from './preview-examples/datepicker-range'; +import DatetimeRange from './preview-examples/datetime-range'; import Datetimepicker from './preview-examples/datetimepicker'; import Divider from './preview-examples/divider'; import Dropdown from './preview-examples/dropdown'; @@ -173,8 +178,8 @@ import MessageBar from './preview-examples/message-bar'; import MessageBarRemoval from './preview-examples/message-bar-removal.tsx'; import Modal from './preview-examples/modal'; import ModalClose from './preview-examples/modal-close.tsx'; -import ModalNonBlockingPreview from './preview-examples/modal-non-blocking.tsx'; import ModalFormIxButtonSubmit from './preview-examples/modal-form-ix-button-submit.tsx'; +import ModalNonBlockingPreview from './preview-examples/modal-non-blocking.tsx'; import ModalSizes from './preview-examples/modal-sizes'; import NumberInput from './preview-examples/number-input'; import NumberInputDisabled from './preview-examples/number-input-disabled'; @@ -301,6 +306,11 @@ const routes: IxPreviewRoutes = { '/preview/breadcrumb-next-items': BreadcrumbNextItems, '/preview/breadcrumb-truncate': BreadcrumbTruncate, '/preview/breadcrumb': Breadcrumb, + '/preview/badge': Badge, + '/preview/badge-dot': BadgeDot, + '/preview/badge-label': BadgeLabel, + '/preview/badge-status-icon': BadgeStatusIcon, + '/preview/badge-counter': BadgeCounter, '/preview/button-tertiary': ButtonTertiary, '/preview/button-subtle-tertiary': ButtonSubtleTertiary, '/preview/button-subtle-secondary': ButtonSubtleSecondary, diff --git a/packages/react-test-app/src/preview-examples/badge-counter.scoped.css b/packages/react-test-app/src/preview-examples/badge-counter.scoped.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-counter.scoped.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/react-test-app/src/preview-examples/badge-counter.tsx b/packages/react-test-app/src/preview-examples/badge-counter.tsx new file mode 100644 index 00000000000..4cf921ae5be --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-counter.tsx @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import './badge-counter.scoped.css'; + +import { IxBadge } from '@siemens/ix-react'; + +const CUSTOM_BACKGROUND = 'var(--theme-chart-11)'; +const CUSTOM_COLOR = 'var(--theme-color-inv-std-text)'; +const CUSTOM_OUTLINE_COLOR = 'var(--theme-chip-outline--color)'; + +export default () => { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-test-app/src/preview-examples/badge-dot.scoped.css b/packages/react-test-app/src/preview-examples/badge-dot.scoped.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-dot.scoped.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/react-test-app/src/preview-examples/badge-dot.tsx b/packages/react-test-app/src/preview-examples/badge-dot.tsx new file mode 100644 index 00000000000..95f9ddf73fd --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-dot.tsx @@ -0,0 +1,262 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import './badge-dot.scoped.css'; + +import { IxBadge } from '@siemens/ix-react'; + +const CUSTOM_BACKGROUND = 'var(--theme-chart-11)'; +const CUSTOM_COLOR = 'var(--theme-color-inv-std-text)'; +const CUSTOM_OUTLINE_COLOR = 'var(--theme-chip-outline--color)'; + +export default () => { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-test-app/src/preview-examples/badge-label.scoped.css b/packages/react-test-app/src/preview-examples/badge-label.scoped.css new file mode 100644 index 00000000000..e8543b9428b --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-label.scoped.css @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} diff --git a/packages/react-test-app/src/preview-examples/badge-label.tsx b/packages/react-test-app/src/preview-examples/badge-label.tsx new file mode 100644 index 00000000000..d6da45ae553 --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-label.tsx @@ -0,0 +1,263 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { iconInfo } from '@siemens/ix-icons/icons'; +import './badge-label.scoped.css'; + +import { IxBadge } from '@siemens/ix-react'; + +const CUSTOM_BACKGROUND = 'var(--theme-chart-11)'; +const CUSTOM_COLOR = 'var(--theme-color-inv-std-text)'; +const CUSTOM_OUTLINE_COLOR = 'var(--theme-chip-outline--color)'; + +export default () => { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-test-app/src/preview-examples/badge-status-icon.scoped.css b/packages/react-test-app/src/preview-examples/badge-status-icon.scoped.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-status-icon.scoped.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/react-test-app/src/preview-examples/badge-status-icon.tsx b/packages/react-test-app/src/preview-examples/badge-status-icon.tsx new file mode 100644 index 00000000000..2126c991cbd --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge-status-icon.tsx @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import './badge-status-icon.scoped.css'; + +import { IxBadge } from '@siemens/ix-react'; + +export default () => { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-test-app/src/preview-examples/badge.scoped.css b/packages/react-test-app/src/preview-examples/badge.scoped.css new file mode 100644 index 00000000000..875f0b6238a --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge.scoped.css @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2rem; + margin-bottom: 1.5rem; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1.5rem; +} + +.styled { + width: 7rem; + --ix-badge-max-width: 7rem; +} + +.styled-ellipsis-4 { + width: 4rem; + --ix-badge-max-width: 4rem; +} diff --git a/packages/react-test-app/src/preview-examples/badge.tsx b/packages/react-test-app/src/preview-examples/badge.tsx new file mode 100644 index 00000000000..f35999bedf3 --- /dev/null +++ b/packages/react-test-app/src/preview-examples/badge.tsx @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { iconInfo, iconStar } from '@siemens/ix-icons/icons'; +import './badge.scoped.css'; + +import { IxBadge, IxButton, IxIconButton } from '@siemens/ix-react'; + +export default () => { + return ( + <> +
+ + Review + + + + + + + + Bottom after + + + + Messages + + + + Device + +
+ +
+ + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + ); +}; diff --git a/packages/react/src/components/components.server.ts b/packages/react/src/components/components.server.ts index c7fb903f835..f348760a3fa 100644 --- a/packages/react/src/components/components.server.ts +++ b/packages/react/src/components/components.server.ts @@ -18,6 +18,7 @@ import { IxActionCard as IxActionCardElement } from "@siemens/ix/components/ix-a import { IxApplicationHeader as IxApplicationHeaderElement } from "@siemens/ix/components/ix-application-header.js"; import { IxApplication as IxApplicationElement } from "@siemens/ix/components/ix-application.js"; import { IxAvatar as IxAvatarElement } from "@siemens/ix/components/ix-avatar.js"; +import { IxBadge as IxBadgeElement } from "@siemens/ix/components/ix-badge.js"; import { IxBlind as IxBlindElement } from "@siemens/ix/components/ix-blind.js"; import { IxBreadcrumbItem as IxBreadcrumbItemElement } from "@siemens/ix/components/ix-breadcrumb-item.js"; import { IxBreadcrumb as IxBreadcrumbElement } from "@siemens/ix/components/ix-breadcrumb.js"; @@ -202,6 +203,32 @@ export const IxAvatar: StencilReactComponent; + +export const IxBadge: StencilReactComponent = /*@__PURE__*/ createComponent({ + tagName: 'ix-badge', + properties: { + type: 'type', + label: 'label', + variant: 'variant', + outline: 'outline', + border: 'border', + position: 'position', + offsetX: 'offset-x', + offsetY: 'offset-y', + enableAnimation: 'enable-animation', + background: 'background', + badgeColor: 'badge-color', + icon: 'icon', + ariaLabelIcon: 'aria-label-icon', + alignLeft: 'align-left', + tooltipText: 'tooltip-text' + }, + hydrateModule: typeof window === 'undefined' ? (import('@siemens/ix/hydrate') as Promise) : undefined, + clientModule: clientComponents.IxBadge as StencilReactComponent, + serializeShadowRoot +}); + export type IxBlindEvents = { onCollapsedChange: EventName> }; export const IxBlind: StencilReactComponent = /*@__PURE__*/ createComponent({ diff --git a/packages/react/src/components/components.ts b/packages/react/src/components/components.ts index 19d788d6cb5..28614aec98f 100644 --- a/packages/react/src/components/components.ts +++ b/packages/react/src/components/components.ts @@ -17,6 +17,7 @@ import { IxActionCard as IxActionCardElement, defineCustomElement as defineIxAct import { IxApplicationHeader as IxApplicationHeaderElement, defineCustomElement as defineIxApplicationHeader } from "@siemens/ix/components/ix-application-header.js"; import { IxApplication as IxApplicationElement, defineCustomElement as defineIxApplication } from "@siemens/ix/components/ix-application.js"; import { IxAvatar as IxAvatarElement, defineCustomElement as defineIxAvatar } from "@siemens/ix/components/ix-avatar.js"; +import { IxBadge as IxBadgeElement, defineCustomElement as defineIxBadge } from "@siemens/ix/components/ix-badge.js"; import { IxBlind as IxBlindElement, defineCustomElement as defineIxBlind } from "@siemens/ix/components/ix-blind.js"; import { IxBreadcrumbItem as IxBreadcrumbItemElement, defineCustomElement as defineIxBreadcrumbItem } from "@siemens/ix/components/ix-breadcrumb-item.js"; import { IxBreadcrumb as IxBreadcrumbElement, defineCustomElement as defineIxBreadcrumb } from "@siemens/ix/components/ix-breadcrumb.js"; @@ -173,6 +174,17 @@ export const IxAvatar: StencilReactComponent; + +export const IxBadge: StencilReactComponent = /*@__PURE__*/ createComponent({ + tagName: 'ix-badge', + elementClass: IxBadgeElement, + // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. + react: React, + events: {} as IxBadgeEvents, + defineCustomElement: defineIxBadge +}); + export type IxBlindEvents = { onCollapsedChange: EventName> }; export const IxBlind: StencilReactComponent = /*@__PURE__*/ createComponent({ diff --git a/packages/storybook-docs/.storybook/define-custom-elements.ts b/packages/storybook-docs/.storybook/define-custom-elements.ts index c2f06c3f4ca..6aad06c5165 100644 --- a/packages/storybook-docs/.storybook/define-custom-elements.ts +++ b/packages/storybook-docs/.storybook/define-custom-elements.ts @@ -4,6 +4,7 @@ import { defineCustomElement as ixActionCard } from '@siemens/ix/components/ix-a import { defineCustomElement as ixApplication } from '@siemens/ix/components/ix-application.js'; import { defineCustomElement as ixApplicationHeader } from '@siemens/ix/components/ix-application-header.js'; import { defineCustomElement as ixAvatar } from '@siemens/ix/components/ix-avatar.js'; +import { defineCustomElement as ixBadge } from '@siemens/ix/components/ix-badge.js'; import { defineCustomElement as ixBlind } from '@siemens/ix/components/ix-blind.js'; import { defineCustomElement as ixBreadcrumb } from '@siemens/ix/components/ix-breadcrumb.js'; import { defineCustomElement as ixBreadcrumbItem } from '@siemens/ix/components/ix-breadcrumb-item.js'; @@ -115,6 +116,7 @@ ixActionCard(); ixApplication(); ixApplicationHeader(); ixAvatar(); +ixBadge(); ixBlind(); ixBreadcrumb(); ixBreadcrumbItem(); diff --git a/packages/storybook-docs/src/stories/badge/accessibility/a11y-badge.stories.tsx b/packages/storybook-docs/src/stories/badge/accessibility/a11y-badge.stories.tsx new file mode 100644 index 00000000000..c50ba44f36b --- /dev/null +++ b/packages/storybook-docs/src/stories/badge/accessibility/a11y-badge.stories.tsx @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import type { Components } from '@siemens/ix/components'; +import { h } from '@stencil/core'; +import type { ArgTypes, Meta, StoryObj } from '@storybook/web-components-vite'; +import { stencil } from '@utils/stencil-render'; +import { makeArgTypes } from '@utils/generic-render'; + +type Element = Components.IxBadge; + +const meta = { + title: 'Example/Badge/Accessibility', + tags: [], + render: stencil((args: Partial) => ), + argTypes: makeArgTypes>>('ix-badge', {}), + parameters: { + a11y: { + test: 'error', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Standalone badge with a text label. + * Visible label text is static chrome (pill-like); no default live region. + */ +export const BasicUsage: Story = { + args: { + label: '12', + variant: 'info', + }, +}; + +/** + * Standalone dot badge: host accessible name requires a naming role + * (e.g. `role="img"`) because `aria-label` alone is not valid on a role-less host. + */ +export const DotWithAriaLabel: Story = { + render: stencil(() => ( + + )), +}; + +/** + * Standalone badge with optional live region: author sets `role` / `aria-label` on the host. + */ +export const CustomAriaLabel: Story = { + render: stencil(() => ( + + )), +}; + +/** + * Attached badge with a count: the anchor keeps its role; count text is exposed via `aria-describedby`. + */ +export const AttachedWithCount: Story = { + render: stencil(() => ( + + Review changes + + )), +}; + +/** + * Attached dot badge: the anchor must be named; the indicator is decorative (`aria-hidden`). + */ +export const AttachedDot: Story = { + render: stencil(() => ( + + + + )), +}; + +/** + * Attached badge merges `aria-describedby` with existing ids on the anchor. + */ +export const MergedAriaDescribedby: Story = { + render: stencil(() => ( +
+ + + +

+ Unread messages in your primary folder. +

+
+ )), +}; + +/** + * Standalone overflow label: numbers above 99 render as `99+`. + */ +export const OverflowLabel: Story = { + args: { + label: '142', + variant: 'primary', + }, +}; + +/** + * Standalone badge hidden from screen readers when the count is redundant with surrounding text. + */ +export const DecorativeBadge: Story = { + render: stencil(() => ( +
+ Notifications: 3 unread + +
+ )), +}; diff --git a/packages/storybook-docs/src/stories/badge/badge-preview.css b/packages/storybook-docs/src/stories/badge/badge-preview.css new file mode 100644 index 00000000000..014818d9eec --- /dev/null +++ b/packages/storybook-docs/src/stories/badge/badge-preview.css @@ -0,0 +1,193 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* Mirrors packages/react-test-app/src/preview-examples/badge.scoped.css */ + +.badge-preview { + display: flex; + flex-direction: column; + gap: 2rem; + padding: 1rem; +} + +.badge-preview h2 { + margin: 0 0 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.badge-preview .edge-intro { + margin: 0; + color: var(--theme-color-soft-text); + font-size: 0.875rem; +} + +.badge-preview .edge-note { + margin: 0 0 0.75rem; + color: var(--theme-color-soft-text); + font-size: 0.8125rem; +} + +.badge-preview .edge-intro code, +.badge-preview .edge-note code { + font-family: var(--theme-font-family-code, monospace); + font-size: 0.75rem; +} + +.badge-preview .theme-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.badge-preview .theme-panel { + padding: 1.5rem; + border-radius: 0.25rem; + background-color: var(--theme-color-1); + color: var(--theme-color-std-text); +} + +.badge-preview .theme-panel-title { + margin: 0 0 1rem; + font-size: 0.875rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge-preview .align-host { + outline: 1px dashed var(--theme-color-soft-bdr); + outline-offset: 0px; +} + +.badge-preview .variant-grid { + display: grid; + grid-template-columns: 5.5rem repeat(4, minmax(5.5rem, 1fr)); + align-items: stretch; +} + +.badge-preview .variant-grid--attached { + grid-template-columns: 5.5rem repeat(3, minmax(5.5rem, 1fr)); +} + +.badge-preview .variant-grid-corner, +.badge-preview .variant-grid-header, +.badge-preview .variant-grid-row-label { + border: 1px solid var(--theme-color-soft-bdr); + background: color-mix(in srgb, var(--theme-color-soft-bdr) 8%, transparent); +} + +.badge-preview .variant-grid-header, +.badge-preview .variant-grid-row-label { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--theme-color-soft-text); + text-align: center; +} + +.badge-preview .variant-grid-row-label { + justify-content: flex-start; + font-size: 0.875rem; +} + +.badge-preview .grid-cell { + box-sizing: border-box; + border: 1px solid var(--theme-color-soft-bdr); + background: color-mix(in srgb, var(--theme-color-soft-bdr) 12%, transparent); + padding: 1rem; + min-height: 4.5rem; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 0.5rem; +} + +.badge-preview .grid-cell--attached, +.badge-preview .variant-grid--attached .grid-cell { + align-items: center; + justify-content: center; + min-height: 5.5rem; +} + +.badge-preview .grid-cell-label { + font-family: var(--theme-font-family-code, monospace); + font-size: 0.625rem; + font-weight: 500; + color: var(--theme-color-soft-text); + line-height: 1.3; + word-break: normal; + overflow-wrap: anywhere; +} + +.badge-preview .grid-cell-pulse-group { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; +} + +.badge-preview .variant-grid ix-badge { + max-width: 100%; +} + +.badge-preview .demo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0; + border: 1px solid var(--theme-color-soft-bdr); + border-radius: 0.25rem; + overflow: hidden; +} + +.badge-preview .demo-grid .grid-cell { + border: none; + border-right: 1px solid var(--theme-color-soft-bdr); + border-bottom: 1px solid var(--theme-color-soft-bdr); + border-radius: 0; +} + +.badge-preview .animation-spec { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.badge-preview .animation-spec-label { + margin: 0; + font-family: var(--theme-font-family-code, monospace); + font-size: 1rem; + font-weight: 400; + color: #9747ff; +} + +.badge-preview .animation-spec-note { + margin: 0; +} + +.badge-preview .animation-pulsing-row, +.badge-preview .animation-live-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0; + border: 1px solid var(--theme-color-soft-bdr); + border-radius: 0.25rem; + overflow: hidden; +} + +.badge-preview .animation-pulsing-row .grid-cell, +.badge-preview .animation-live-row .grid-cell { + border: none; + border-right: 1px solid var(--theme-color-soft-bdr); + border-bottom: 1px solid var(--theme-color-soft-bdr); + border-radius: 0; +} diff --git a/packages/storybook-docs/src/stories/badge/badge-preview.shared.tsx b/packages/storybook-docs/src/stories/badge/badge-preview.shared.tsx new file mode 100644 index 00000000000..4d59b7b5d21 --- /dev/null +++ b/packages/storybook-docs/src/stories/badge/badge-preview.shared.tsx @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { h, type VNode } from '@stencil/core'; +import './badge-preview.css'; + +/** Aligned with chip / react badge preview custom tokens. */ +export const CUSTOM_BADGE_BACKGROUND = 'var(--theme-chart-11)'; +export const CUSTOM_BADGE_COLOR = 'var(--theme-color-inv-std-text)'; +export const CUSTOM_BADGE_OUTLINE_COLOR = 'var(--theme-chip-outline--color)'; + +export const VARIANT_ROWS = [ + { label: 'Primary', variant: 'primary' }, + { label: 'Alarm', variant: 'alarm' }, + { label: 'Critical', variant: 'critical' }, + { label: 'Warning', variant: 'warning' }, + { label: 'Info', variant: 'info' }, + { label: 'Success', variant: 'success' }, + { label: 'Neutral', variant: 'neutral' }, + { label: 'Custom', variant: 'custom' }, +] as const; + +export const STATUS_ICON_VARIANT_ROWS = [ + { label: 'Alarm', variant: 'alarm' }, + { label: 'Error', variant: 'error' }, + { label: 'Critical', variant: 'critical' }, + { label: 'Warning', variant: 'warning' }, + { label: 'Success', variant: 'success' }, + { label: 'Info', variant: 'info' }, +] as const; + +export type BadgeVariant = (typeof VARIANT_ROWS)[number]['variant']; +export type StatusIconVariant = + (typeof STATUS_ICON_VARIANT_ROWS)[number]['variant']; + +/** Showcase stories — a11y deep-dive stays under Example/Badge/Accessibility. */ +export const showcaseParameters = { + layout: 'fullscreen' as const, + a11y: { + test: 'off' as const, + }, +}; + +export function cell( + label: string, + content: VNode | null | undefined, + attached = false +): VNode { + return ( +
+ {label} + {content} +
+ ); +} + +export function themePanel( + colorSchema: 'dark' | 'light', + title: string, + content: VNode +): VNode { + return ( +
+

{title}

+ {content} +
+ ); +} + +export function variantGridHeaders(): VNode[] { + return [ + , + + Filled + , + + Outline + , + + Border + , + + Pulse + , + ]; +} + +/** Attached grids omit Outline — not recommended on anchors. */ +export function attachedVariantGridHeaders(): VNode[] { + return [ + , + + Filled + , + + Border + , + + Pulse + , + ]; +} + +export function iconButtonAnchor(ariaLabel: string): VNode { + return ; +} + +type VariantRow = { + label: string; + variant: V; +}; + +type VariantGridBadgeFactory = (options: { + variant: V; + outline?: boolean; + border?: boolean; + enableAnimation?: boolean; + children?: VNode | null; +}) => VNode; + +export function buildStandaloneVariantGrid( + rows: readonly VariantRow[], + createBadge: VariantGridBadgeFactory +): VNode { + return ( +
+ {variantGridHeaders()} + {rows.flatMap(({ label, variant }) => [ + + {label} + , +
+ {`${variant} · filled`} + {createBadge({ variant })} +
, +
+ {`${variant} · outline`} + {createBadge({ variant, outline: true })} +
, +
+ {`${variant} · border`} + {createBadge({ variant, border: true })} +
, +
+ {`${variant} · pulse`} +
+ {createBadge({ variant, enableAnimation: true })} + {createBadge({ variant, outline: true, enableAnimation: true })} +
+
, + ])} +
+ ); +} + +export function buildAttachedVariantGrid( + rows: readonly VariantRow[], + createBadge: VariantGridBadgeFactory +): VNode { + return ( +
+ {attachedVariantGridHeaders()} + {rows.flatMap(({ label, variant }) => [ + + {label} + , +
+ {`${variant} · filled · attached`} + {createBadge({ + variant, + children: iconButtonAnchor(`${label} anchor`), + })} +
, +
+ {`${variant} · border · attached`} + {createBadge({ + variant, + border: true, + children: iconButtonAnchor(`${label} anchor`), + })} +
, +
+ {`${variant} · pulse · attached`} + {createBadge({ + variant, + enableAnimation: true, + children: iconButtonAnchor(`${label} anchor`), + })} +
, + ])} +
+ ); +} + +type CustomColors = { + background?: string; + badgeColor?: string; +}; + +function resolveCustomBackground( + variant: BadgeVariant, + custom?: CustomColors +): string | undefined { + if (custom?.background) { + return custom.background; + } + return variant === 'custom' ? CUSTOM_BADGE_BACKGROUND : undefined; +} + +function resolveCustomBadgeColor( + variant: BadgeVariant, + outline: boolean, + custom?: CustomColors +): string | undefined { + if (custom?.badgeColor) { + return custom.badgeColor; + } + if (variant !== 'custom') { + return undefined; + } + return outline ? CUSTOM_BADGE_OUTLINE_COLOR : CUSTOM_BADGE_COLOR; +} + +export function counterBadge(options: { + variant: BadgeVariant; + label?: string; + outline?: boolean; + border?: boolean; + enableAnimation?: boolean; + position?: 'top-after' | 'bottom-after'; + offsetX?: number; + offsetY?: number; + hostStyle?: Record; + custom?: CustomColors; + children?: VNode | null; +}): VNode { + const { + variant, + label = '1', + outline = false, + border = false, + enableAnimation = false, + position, + offsetX, + offsetY, + hostStyle, + custom, + children, + } = options; + + const background = resolveCustomBackground(variant, custom); + const badgeColor = resolveCustomBadgeColor(variant, outline, custom); + + return ( + + {children} + + ); +} + +export function labelBadge(options: { + variant: BadgeVariant; + label?: string; + icon?: string; + ariaLabelIcon?: string; + alignLeft?: boolean; + outline?: boolean; + border?: boolean; + enableAnimation?: boolean; + position?: 'top-after' | 'bottom-after'; + offsetX?: number; + offsetY?: number; + hostStyle?: Record; + ariaLabel?: string; + role?: string; + custom?: CustomColors; + children?: VNode | null; +}): VNode { + const { + variant, + label = 'NEW', + icon = 'star', + ariaLabelIcon, + alignLeft = false, + outline = false, + border = false, + enableAnimation = false, + position, + offsetX, + offsetY, + hostStyle, + ariaLabel, + role, + custom, + children, + } = options; + + const background = resolveCustomBackground(variant, custom); + const badgeColor = resolveCustomBadgeColor(variant, outline, custom); + + return ( + + {children} + + ); +} + +export function dotBadge(options: { + variant: BadgeVariant; + outline?: boolean; + border?: boolean; + enableAnimation?: boolean; + position?: 'top-after' | 'bottom-after'; + offsetX?: number; + offsetY?: number; + ariaLabel?: string; + role?: string; + custom?: CustomColors; + children?: VNode | null; +}): VNode { + const { + variant, + outline = false, + border = false, + enableAnimation = false, + position, + offsetX, + offsetY, + ariaLabel, + role, + custom, + children, + } = options; + + const background = resolveCustomBackground(variant, custom); + const badgeColor = resolveCustomBadgeColor(variant, outline, custom); + + return ( + + {children} + + ); +} + +export function statusIconBadge(options: { + variant: StatusIconVariant; + outline?: boolean; + border?: boolean; + enableAnimation?: boolean; + position?: 'top-after' | 'bottom-after'; + offsetX?: number; + offsetY?: number; + ariaLabel?: string; + role?: string; + children?: VNode | null; +}): VNode { + const { + variant, + outline = false, + border = false, + enableAnimation = false, + position, + offsetX, + offsetY, + ariaLabel, + role, + children, + } = options; + + return ( + + {children} + + ); +} diff --git a/packages/storybook-docs/src/stories/badge/counter.stories.tsx b/packages/storybook-docs/src/stories/badge/counter.stories.tsx new file mode 100644 index 00000000000..72bdb327f28 --- /dev/null +++ b/packages/storybook-docs/src/stories/badge/counter.stories.tsx @@ -0,0 +1,379 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { h, type VNode } from '@stencil/core'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { stencil } from '@utils/stencil-render'; +import { + buildAttachedVariantGrid, + buildStandaloneVariantGrid, + cell, + counterBadge, + iconButtonAnchor, + showcaseParameters, + themePanel, + VARIANT_ROWS, +} from './badge-preview.shared'; + +/** + * Mirrors `packages/react-test-app/src/preview-examples/badge.tsx` + * (`/preview/badge`). + */ +function standaloneVariantGrid(): VNode { + return buildStandaloneVariantGrid(VARIANT_ROWS, counterBadge); +} + +function attachedVariantGrid(): VNode { + return buildAttachedVariantGrid(VARIANT_ROWS, counterBadge); +} + +function badgeCounterPreview(): VNode { + return ( +
+
+

type="counter"

+

+ Solid cell border = alignment grid; dashed outline ={' '} + ix-badge host bounds. +

+
+ +
+

Variants — standalone

+

No default slot → standalone counter.

+ {themePanel('dark', 'Dark', standaloneVariantGrid())} + {themePanel('light', 'Light', standaloneVariantGrid())} +
+ +
+

Variants — attached (anchor element)

+

+ Default slot wraps anchor; counter overlays top-trailing (−10px / + −10px default offset). +

+ {themePanel('dark', 'Dark', attachedVariantGrid())} + {themePanel('light', 'Light', attachedVariantGrid())} +
+ +
+

Standalone counts

+
+ {cell( + 'type="counter" label="1"', + counterBadge({ variant: 'primary', label: '1' }) + )} + {cell( + 'type="counter" label="12"', + counterBadge({ variant: 'alarm', label: '12' }) + )} + {cell( + 'type="counter" label="99"', + counterBadge({ variant: 'warning', label: '99' }) + )} + {cell( + 'type="counter" label="100" → 99+', + counterBadge({ variant: 'info', label: '100' }) + )} + {cell( + 'type="counter" label="142" → 99+', + counterBadge({ variant: 'critical', label: '142' }) + )} + {cell( + 'type="counter" label="0"', + counterBadge({ variant: 'neutral', label: '0' }) + )} + {cell( + 'type="counter" label="-1"', + counterBadge({ variant: 'success', label: '-1' }) + )} + {cell( + 'type="counter" label="-99"', + counterBadge({ variant: 'primary', label: '-99' }) + )} + {cell( + 'type="counter" label="-100" (literal)', + counterBadge({ variant: 'alarm', label: '-100' }) + )} +
+
+ +
+

Attached anchors

+
+ {cell( + 'IxButton anchor', + counterBadge({ + variant: 'alarm', + label: '3', + children: Notifications, + }), + true + )} + {cell( + 'IxIconButton anchor', + counterBadge({ + variant: 'info', + label: '5', + children: iconButtonAnchor("What's new"), + }), + true + )} + {cell( + 'IxAvatar anchor', + counterBadge({ + variant: 'primary', + label: '12', + children: , + }), + true + )} +
+
+ +
+

Formatting

+
+ {cell('label="1"', counterBadge({ variant: 'alarm', label: '1' }))} + {cell('label="99"', counterBadge({ variant: 'alarm', label: '99' }))} + {cell( + 'label="100" → 99+', + counterBadge({ variant: 'alarm', label: '100' }) + )} + {cell( + 'label="3.7" → 3', + counterBadge({ variant: 'alarm', label: '3.7' }) + )} + {cell( + 'label="99.9" → 99', + counterBadge({ variant: 'alarm', label: '99.9' }) + )} + {cell( + 'label="007" → 7', + counterBadge({ variant: 'alarm', label: '007' }) + )} + {cell('label="-1"', counterBadge({ variant: 'alarm', label: '-1' }))} + {cell( + 'label="new" → no indicator', + counterBadge({ variant: 'alarm', label: 'new' }) + )} + {cell( + 'label="99+" → 99+', + counterBadge({ variant: 'alarm', label: '99+' }) + )} + {cell( + 'no label → no indicator', + + )} +
+
+ +
+

Position and offset

+
+ {cell( + 'position="top-after" (default)', + counterBadge({ + variant: 'warning', + label: '5', + children: Top after, + }), + true + )} + {cell( + 'position="bottom-after"', + counterBadge({ + variant: 'warning', + label: '99', + position: 'bottom-after', + children: , + }), + true + )} + {cell( + 'offsetX={6} offsetY={-4}', + counterBadge({ + variant: 'info', + label: '2', + offsetX: 6, + offsetY: -4, + children: Offset, + }), + true + )} + {cell( + 'Custom offset', + counterBadge({ + variant: 'alarm', + label: '1', + offsetX: -4, + offsetY: 2, + children: Custom offset, + }), + true + )} +
+
+ +
+

Animation

+
+

Pulsing (bool)

+

warning · counter

+
+ {cell('filled', counterBadge({ variant: 'warning', label: '1' }))} + {cell( + 'filled · pulse', + counterBadge({ + variant: 'warning', + label: '1', + enableAnimation: true, + }) + )} + {cell( + 'outline', + counterBadge({ variant: 'warning', label: '1', outline: true }) + )} + {cell( + 'outline · pulse', + counterBadge({ + variant: 'warning', + label: '1', + outline: true, + enableAnimation: true, + }) + )} +
+ +

Live pulse

+
+ {cell( + 'standalone · 2s', + counterBadge({ + variant: 'primary', + label: '1', + enableAnimation: true, + }) + )} + {cell( + 'attached · 2s', + counterBadge({ + variant: 'primary', + label: '1', + enableAnimation: true, + children: iconButtonAnchor("What's new"), + }), + true + )} + {cell( + 'standalone · 6s', + counterBadge({ + variant: 'warning', + label: '1', + enableAnimation: true, + hostStyle: { '--ix-badge-animation-duration': '6s' }, + }) + )} +
+
+
+ +
+

Width

+
+ {cell('label="1"', counterBadge({ variant: 'primary', label: '1' }))} + {cell( + 'label="12"', + counterBadge({ variant: 'primary', label: '12' }) + )} + {cell( + 'label="99"', + counterBadge({ variant: 'primary', label: '99' }) + )} + {cell( + 'label="142" → 99+', + counterBadge({ variant: 'primary', label: '142' }) + )} + {cell( + '--ix-badge-max-width: 2rem', + counterBadge({ + variant: 'primary', + label: '142', + hostStyle: { '--ix-badge-max-width': '2rem' }, + }) + )} +
+
+ +
+

Container bounds

+
+ {cell( + '32px IxIconButton', + counterBadge({ + variant: 'alarm', + label: '1', + children: ( + + ), + }), + true + )} + {cell( + '24px IxIcon size="24"', + counterBadge({ + variant: 'alarm', + label: '12', + children: ( + + ), + }), + true + )} +
+
+ +
+

Accessibility

+
+ {cell( + 'role="alert" · standalone', + + )} +
+
+
+ ); +} + +const meta = { + title: 'Example/Badge', + tags: [], + parameters: showcaseParameters, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Counter: Story = { + name: 'Counter', + render: stencil(() => badgeCounterPreview()), +}; diff --git a/packages/storybook-docs/src/stories/badge/dot.stories.tsx b/packages/storybook-docs/src/stories/badge/dot.stories.tsx new file mode 100644 index 00000000000..ecf00812150 --- /dev/null +++ b/packages/storybook-docs/src/stories/badge/dot.stories.tsx @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { h, type VNode } from '@stencil/core'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { stencil } from '@utils/stencil-render'; +import { + buildAttachedVariantGrid, + buildStandaloneVariantGrid, + cell, + dotBadge, + iconButtonAnchor, + showcaseParameters, + themePanel, + VARIANT_ROWS, +} from './badge-preview.shared'; + +/** + * Mirrors `packages/react-test-app/src/preview-examples/badge-dot.tsx` + * (`/preview/badge-dot`). + */ +function standaloneVariantGrid(): VNode { + return buildStandaloneVariantGrid(VARIANT_ROWS, dotBadge); +} + +function attachedVariantGrid(): VNode { + return buildAttachedVariantGrid(VARIANT_ROWS, dotBadge); +} + +function badgeDotPreview(): VNode { + return ( +
+
+

Dot badge

+

+ type="dot" + {' · 12×12px circle · no '} + label + { + '. Attached default offset −6px / −6px (top-trailing). Standalone dots need ' + } + aria-label + {' on the host; attached dots are decorative (anchor carries meaning).'} +

+
+ +
+ {themePanel('dark', 'Dark', standaloneVariantGrid())} + {themePanel('light', 'Light', standaloneVariantGrid())} +
+ +
+

Variants — attached

+

+ Icon button anchor; dot overlays top-trailing (−0.375rem default + offset). +

+ {themePanel('dark', 'Dark', attachedVariantGrid())} + {themePanel('light', 'Light', attachedVariantGrid())} +
+ +
+

Attached anchors

+
+ {cell( + 'IxIconButton · alarm', + dotBadge({ + variant: 'alarm', + children: iconButtonAnchor("What's new"), + }), + true + )} + {cell( + 'IxButton · primary', + dotBadge({ + variant: 'primary', + children: Messages, + }), + true + )} + {cell( + 'IxAvatar · success', + dotBadge({ + variant: 'success', + children: , + }), + true + )} +
+
+ +
+

Placement

+
+ {cell( + 'position="top-after" (default)', + dotBadge({ + variant: 'alarm', + children: iconButtonAnchor('Notifications'), + }), + true + )} + {cell( + 'position="bottom-after"', + dotBadge({ + variant: 'info', + position: 'bottom-after', + children: iconButtonAnchor('Notifications'), + }), + true + )} + {cell( + 'offsetX={6} offsetY={-4}', + dotBadge({ + variant: 'warning', + offsetX: 6, + offsetY: -4, + children: iconButtonAnchor('Notifications'), + }), + true + )} +
+
+ +
+

Standalone a11y

+

+ Set aria-label on ix-badge + {' (no '} + aria-describedby + {' on anchor when attached).'} +

+
+ {cell( + 'aria-label="Unread messages"', + dotBadge({ + variant: 'alarm', + ariaLabel: 'Unread messages', + }) + )} + {cell( + 'role="alert" · aria-label="Action required"', + dotBadge({ + variant: 'critical', + role: 'alert', + ariaLabel: 'Action required', + }) + )} +
+
+ +
+

Pulse

+
+ {cell( + 'filled · pulse · standalone', + dotBadge({ variant: 'warning', enableAnimation: true }) + )} + {cell( + 'filled · pulse · attached', + dotBadge({ + variant: 'warning', + enableAnimation: true, + children: iconButtonAnchor('Live updates'), + }), + true + )} +
+
+
+ ); +} + +const meta = { + title: 'Example/Badge', + tags: [], + parameters: showcaseParameters, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Dot: Story = { + name: 'Dot', + render: stencil(() => badgeDotPreview()), +}; diff --git a/packages/storybook-docs/src/stories/badge/label.stories.tsx b/packages/storybook-docs/src/stories/badge/label.stories.tsx new file mode 100644 index 00000000000..13481d2b2f9 --- /dev/null +++ b/packages/storybook-docs/src/stories/badge/label.stories.tsx @@ -0,0 +1,245 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { h, type VNode } from '@stencil/core'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { stencil } from '@utils/stencil-render'; +import { + buildAttachedVariantGrid, + buildStandaloneVariantGrid, + cell, + iconButtonAnchor, + labelBadge, + showcaseParameters, + themePanel, + VARIANT_ROWS, +} from './badge-preview.shared'; + +/** + * Mirrors `packages/react-test-app/src/preview-examples/badge-label.tsx` + * (`/preview/badge-label`). + */ +function standaloneVariantGrid(): VNode { + return buildStandaloneVariantGrid(VARIANT_ROWS, labelBadge); +} + +function attachedVariantGrid(): VNode { + return buildAttachedVariantGrid(VARIANT_ROWS, labelBadge); +} + +function badgeLabelPreview(): VNode { + return ( +
+
+

Label badge

+

+ type="label" + {' · text pill (20px height) · '} + label + {' required · body-regular typography · leading '} + icon + {' in examples. Optional '} + aria-label-icon + {' and '} + align-left + {'. Attached default offset −10px / −10px.'} +

+
+ +
+ {themePanel('dark', 'Dark', standaloneVariantGrid())} + {themePanel('light', 'Light', standaloneVariantGrid())} +
+ +
+

Variants — attached

+

+ Icon button anchor; label overlays top-trailing (−0.625rem default + offset). +

+ {themePanel('dark', 'Dark', attachedVariantGrid())} + {themePanel('light', 'Light', attachedVariantGrid())} +
+ +
+

Icon

+
+ {cell( + 'icon={star} · decorative (default)', + labelBadge({ variant: 'primary' }) + )} + {cell( + 'icon={star} · decorative · warning', + labelBadge({ variant: 'warning' }) + )} + {cell( + 'icon={star} · aria-label-icon="Featured"', + labelBadge({ + variant: 'success', + label: 'Featured', + ariaLabelIcon: 'Featured', + }) + )} +
+
+ +
+

Align left

+
+ {cell( + 'default · icon + text', + labelBadge({ variant: 'info', label: "What's new" }) + )} + {cell( + 'alignLeft · icon + text', + labelBadge({ + variant: 'primary', + label: "What's new", + alignLeft: true, + }) + )} + {cell( + 'alignLeft · --ix-badge-max-width: 2.5rem', + labelBadge({ + variant: 'alarm', + label: "What's new", + alignLeft: true, + hostStyle: { '--ix-badge-max-width': '2.5rem' }, + }) + )} +
+
+ +
+

Attached anchors

+
+ {cell( + 'IxIconButton · alarm', + labelBadge({ + variant: 'alarm', + children: iconButtonAnchor("What's new"), + }), + true + )} + {cell( + 'IxButton · primary · warning badge', + labelBadge({ + variant: 'warning', + children: Messages, + }), + true + )} + {cell( + 'IxAvatar · success', + labelBadge({ + variant: 'success', + children: , + }), + true + )} +
+
+ +
+

Placement

+
+ {cell( + 'position="top-after" (default)', + labelBadge({ + variant: 'alarm', + children: iconButtonAnchor('Notifications'), + }), + true + )} + {cell( + 'position="bottom-after"', + labelBadge({ + variant: 'info', + position: 'bottom-after', + children: iconButtonAnchor('Notifications'), + }), + true + )} + {cell( + 'offsetX={6} offsetY={-4}', + labelBadge({ + variant: 'warning', + offsetX: 6, + offsetY: -4, + children: iconButtonAnchor('Notifications'), + }), + true + )} +
+
+ +
+

Standalone a11y

+

+ Attached: label text via aria-describedby + { + ' on anchor. Standalone: author ARIA stays on the host (pill-like; opt-in live region).' + } +

+
+ {cell( + 'aria-label="New content available"', + labelBadge({ + variant: 'primary', + label: 'NEW', + ariaLabel: 'New content available', + }) + )} + {cell( + 'role="alert" · aria-label="Action required"', + labelBadge({ + variant: 'critical', + label: '!', + role: 'alert', + ariaLabel: 'Action required', + }) + )} +
+
+ +
+

Pulse

+
+ {cell( + 'filled · pulse · standalone', + labelBadge({ variant: 'warning', enableAnimation: true }) + )} + {cell( + 'filled · pulse · attached', + labelBadge({ + variant: 'warning', + enableAnimation: true, + children: iconButtonAnchor('Live updates'), + }), + true + )} +
+
+
+ ); +} + +const meta = { + title: 'Example/Badge', + tags: [], + parameters: showcaseParameters, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Label: Story = { + name: 'Label', + render: stencil(() => badgeLabelPreview()), +}; diff --git a/packages/storybook-docs/src/stories/badge/status-icon.stories.tsx b/packages/storybook-docs/src/stories/badge/status-icon.stories.tsx new file mode 100644 index 00000000000..8e8d9634c96 --- /dev/null +++ b/packages/storybook-docs/src/stories/badge/status-icon.stories.tsx @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { h, type VNode } from '@stencil/core'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { stencil } from '@utils/stencil-render'; +import { + buildAttachedVariantGrid, + buildStandaloneVariantGrid, + cell, + iconButtonAnchor, + showcaseParameters, + STATUS_ICON_VARIANT_ROWS, + statusIconBadge, + themePanel, +} from './badge-preview.shared'; + +/** + * Mirrors `packages/react-test-app/src/preview-examples/badge-status-icon.tsx` + * (`/preview/badge-status-icon`). + */ +function standaloneVariantGrid(): VNode { + return buildStandaloneVariantGrid(STATUS_ICON_VARIANT_ROWS, statusIconBadge); +} + +function attachedVariantGrid(): VNode { + return buildAttachedVariantGrid(STATUS_ICON_VARIANT_ROWS, statusIconBadge); +} + +function badgeStatusIconPreview(): VNode { + return ( +
+
+

Status-icon badge

+

+ type="status-icon" + {' · 20×20px circle · icon from '} + variant + {' · no '} + label + { + '. Attached default offset −10px / −10px. Same a11y as dot: attached = decorative; standalone needs ' + } + aria-label + {' on the host.'} +

+
+ +
+ {themePanel('dark', 'Dark', standaloneVariantGrid())} + {themePanel('light', 'Light', standaloneVariantGrid())} +
+ +
+

Variants — attached

+

+ Icon button anchor; status icon overlays top-trailing (−0.625rem + default offset). +

+ {themePanel('dark', 'Dark', attachedVariantGrid())} + {themePanel('light', 'Light', attachedVariantGrid())} +
+ +
+

Attached anchors

+
+ {cell( + 'IxIconButton · alarm', + statusIconBadge({ + variant: 'alarm', + children: iconButtonAnchor('Error state'), + }), + true + )} + {cell( + 'IxButton · primary · warning badge', + statusIconBadge({ + variant: 'warning', + children: Messages, + }), + true + )} + {cell( + 'IxAvatar · success', + statusIconBadge({ + variant: 'success', + children: , + }), + true + )} +
+
+ +
+

Placement

+
+ {cell( + 'position="top-after" (default)', + statusIconBadge({ + variant: 'alarm', + children: iconButtonAnchor('Notifications'), + }), + true + )} + {cell( + 'position="bottom-after"', + statusIconBadge({ + variant: 'info', + position: 'bottom-after', + children: iconButtonAnchor('Notifications'), + }), + true + )} + {cell( + 'offsetX={6} offsetY={-4}', + statusIconBadge({ + variant: 'warning', + offsetX: 6, + offsetY: -4, + children: iconButtonAnchor('Notifications'), + }), + true + )} +
+
+ +
+

Standalone a11y

+

+ Set aria-label on ix-badge + {' (no '} + aria-describedby + {' on anchor when attached).'} +

+
+ {cell( + 'aria-label="Action required"', + statusIconBadge({ + variant: 'alarm', + ariaLabel: 'Action required', + }) + )} + {cell( + 'role="alert" · aria-label="Critical failure"', + statusIconBadge({ + variant: 'critical', + role: 'alert', + ariaLabel: 'Critical failure', + }) + )} +
+
+ +
+

Pulse

+
+ {cell( + 'filled · pulse · standalone', + statusIconBadge({ variant: 'warning', enableAnimation: true }) + )} + {cell( + 'filled · pulse · attached', + statusIconBadge({ + variant: 'warning', + enableAnimation: true, + children: iconButtonAnchor('Live updates'), + }), + true + )} +
+
+
+ ); +} + +const meta = { + title: 'Example/Badge', + tags: [], + parameters: showcaseParameters, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const StatusIcon: Story = { + name: 'Status icon', + render: stencil(() => badgeStatusIconPreview()), +}; diff --git a/packages/vue-test-app/src/Root.vue b/packages/vue-test-app/src/Root.vue index f676d0b3ace..a6350968be0 100644 --- a/packages/vue-test-app/src/Root.vue +++ b/packages/vue-test-app/src/Root.vue @@ -26,6 +26,11 @@ import ApplicationAdvanced from './preview-examples/application-advanced.vue'; import AvatarImage from './preview-examples/avatar-image.vue'; import AvatarInitials from './preview-examples/avatar-initials.vue'; import Avatar from './preview-examples/avatar.vue'; +import Badge from './preview-examples/badge.vue'; +import BadgeCounter from './preview-examples/badge-counter.vue'; +import BadgeDot from './preview-examples/badge-dot.vue'; +import BadgeLabel from './preview-examples/badge-label.vue'; +import BadgeStatusIcon from './preview-examples/badge-status-icon.vue'; import BlindHeaderActions from './preview-examples/blind-header-actions.vue'; import BlindVariants from './preview-examples/blind-variants.vue'; import Blind from './preview-examples/blind.vue'; @@ -276,6 +281,11 @@ const routes: IxPreviewRoutes = { '/preview/avatar-image': AvatarImage, '/preview/avatar-initials': AvatarInitials, '/preview/avatar': Avatar, + '/preview/badge': Badge, + '/preview/badge-counter': BadgeCounter, + '/preview/badge-dot': BadgeDot, + '/preview/badge-label': BadgeLabel, + '/preview/badge-status-icon': BadgeStatusIcon, '/preview/blind-header-actions': BlindHeaderActions, '/preview/blind-variants': BlindVariants, '/preview/blind': Blind, diff --git a/packages/vue-test-app/src/preview-examples/badge-counter.css b/packages/vue-test-app/src/preview-examples/badge-counter.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-counter.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/vue-test-app/src/preview-examples/badge-counter.vue b/packages/vue-test-app/src/preview-examples/badge-counter.vue new file mode 100644 index 00000000000..1e842d58a5b --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-counter.vue @@ -0,0 +1,59 @@ + + + + + + + diff --git a/packages/vue-test-app/src/preview-examples/badge-dot.css b/packages/vue-test-app/src/preview-examples/badge-dot.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-dot.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/vue-test-app/src/preview-examples/badge-dot.vue b/packages/vue-test-app/src/preview-examples/badge-dot.vue new file mode 100644 index 00000000000..16f06970ddb --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-dot.vue @@ -0,0 +1,59 @@ + + + + + + + diff --git a/packages/vue-test-app/src/preview-examples/badge-label.css b/packages/vue-test-app/src/preview-examples/badge-label.css new file mode 100644 index 00000000000..e8543b9428b --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-label.css @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} diff --git a/packages/vue-test-app/src/preview-examples/badge-label.vue b/packages/vue-test-app/src/preview-examples/badge-label.vue new file mode 100644 index 00000000000..c9dbf8254d7 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-label.vue @@ -0,0 +1,52 @@ + + + + + + + diff --git a/packages/vue-test-app/src/preview-examples/badge-status-icon.css b/packages/vue-test-app/src/preview-examples/badge-status-icon.css new file mode 100644 index 00000000000..2744070b513 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-status-icon.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 2rem; + row-gap: 1.5rem; + align-items: center; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1rem; +} + diff --git a/packages/vue-test-app/src/preview-examples/badge-status-icon.vue b/packages/vue-test-app/src/preview-examples/badge-status-icon.vue new file mode 100644 index 00000000000..a5a085253e6 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge-status-icon.vue @@ -0,0 +1,49 @@ + + + + + + + diff --git a/packages/vue-test-app/src/preview-examples/badge.css b/packages/vue-test-app/src/preview-examples/badge.css new file mode 100644 index 00000000000..875f0b6238a --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge.css @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2rem; + margin-bottom: 1.5rem; + /* Room for attached overlays and pulse rings above the content. */ + padding-top: 1.5rem; +} + +.styled { + width: 7rem; + --ix-badge-max-width: 7rem; +} + +.styled-ellipsis-4 { + width: 4rem; + --ix-badge-max-width: 4rem; +} diff --git a/packages/vue-test-app/src/preview-examples/badge.vue b/packages/vue-test-app/src/preview-examples/badge.vue new file mode 100644 index 00000000000..e12a17fc477 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/badge.vue @@ -0,0 +1,49 @@ + + + + + + + diff --git a/packages/vue/src/components/components.ts b/packages/vue/src/components/components.ts index 93d43296420..a4443b784ce 100644 --- a/packages/vue/src/components/components.ts +++ b/packages/vue/src/components/components.ts @@ -5,6 +5,7 @@ export { IxActionCard } from './ix-action-card.js'; export { IxApplication } from './ix-application.js'; export { IxApplicationHeader } from './ix-application-header.js'; export { IxAvatar } from './ix-avatar.js'; +export { IxBadge } from './ix-badge.js'; export { IxBlind } from './ix-blind.js'; export { IxBreadcrumb } from './ix-breadcrumb.js'; export { IxBreadcrumbItem } from './ix-breadcrumb-item.js'; diff --git a/packages/vue/src/components/ix-badge.ts b/packages/vue/src/components/ix-badge.ts new file mode 100644 index 00000000000..9dc4837eafe --- /dev/null +++ b/packages/vue/src/components/ix-badge.ts @@ -0,0 +1,25 @@ +/* eslint-disable */ +/* tslint:disable */ +/* auto-generated vue proxy */ +import { defineContainer, type StencilVueComponent } from '@stencil/vue-output-target/runtime'; + +import type { JSX } from '@siemens/ix'; +import { defineCustomElement as defineIxBadge } from '@siemens/ix/components/ix-badge.js'; + +export const IxBadge: StencilVueComponent = /*@__PURE__*/ defineContainer('ix-badge', defineIxBadge, [ + 'type', + 'label', + 'variant', + 'outline', + 'border', + 'position', + 'offsetX', + 'offsetY', + 'enableAnimation', + 'background', + 'badgeColor', + 'icon', + 'ariaLabelIcon', + 'alignLeft', + 'tooltipText' +]); diff --git a/testing/framework-tests/__snapshots__/badge-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/badge-aria-snapshot.yaml new file mode 100644 index 00000000000..2c9e633a513 --- /dev/null +++ b/testing/framework-tests/__snapshots__/badge-aria-snapshot.yaml @@ -0,0 +1,30 @@ +- main: + - text: "3" + - button "Review, 3 unread" + - text: "1" + - button "What's new, 1 notification" + - text: "9" + - button "Bottom after, 9 items" + - button "Messages, unread" + - button "Device, warning" + - text: /\d+ \d+ \d+\+ \d+\+ 8 8/ + - img "Unread" + - img "Online" + - img "Alarm" + - img "Success" + - text: Label + - tooltip "Label" + - text: Label + - tooltip "Label" + - text: /\d+/ + - tooltip /\d+/ + - text: /\d+/ + - tooltip "Twelve notifications" + - img "Unread": + - tooltip "Unread" + - img "Unread": + - tooltip "Custom tooltip text" + - img "Warning": + - tooltip "Warning" + - img "Warning": + - tooltip "Custom tooltip text" \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/badge-counter-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/badge-counter-aria-snapshot.yaml new file mode 100644 index 00000000000..5d605d2d5c0 --- /dev/null +++ b/testing/framework-tests/__snapshots__/badge-counter-aria-snapshot.yaml @@ -0,0 +1 @@ +- main: 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/badge-dot-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/badge-dot-aria-snapshot.yaml new file mode 100644 index 00000000000..4c1f55b5a2b --- /dev/null +++ b/testing/framework-tests/__snapshots__/badge-dot-aria-snapshot.yaml @@ -0,0 +1,33 @@ +- main: + - img "Primary" + - img "Primary outline" + - img "Primary pulse" + - img "Primary pulse outline" + - img "Alarm" + - img "Alarm outline" + - img "Alarm pulse" + - img "Alarm pulse outline" + - img "Critical" + - img "Critical outline" + - img "Critical pulse" + - img "Critical pulse outline" + - img "Warning" + - img "Warning outline" + - img "Warning pulse" + - img "Warning pulse outline" + - img "Info" + - img "Info outline" + - img "Info pulse" + - img "Info pulse outline" + - img "Neutral" + - img "Neutral outline" + - img "Neutral pulse" + - img "Neutral pulse outline" + - img "Success" + - img "Success outline" + - img "Success pulse" + - img "Success pulse outline" + - img "Custom" + - img "Custom outline" + - img "Custom pulse" + - img "Custom pulse outline" \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/badge-label-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/badge-label-aria-snapshot.yaml new file mode 100644 index 00000000000..4968d3b4261 --- /dev/null +++ b/testing/framework-tests/__snapshots__/badge-label-aria-snapshot.yaml @@ -0,0 +1 @@ +- main: Primary Primary Primary Primary Alarm Alarm Alarm Alarm Critical Critical Critical Critical Warning Warning Warning Warning Info Info Info Info Neutral Neutral Neutral Neutral Success Success Success Success Custom Custom Custom Custom diff --git a/testing/framework-tests/__snapshots__/badge-status-icon-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/badge-status-icon-aria-snapshot.yaml new file mode 100644 index 00000000000..ea45513b2b6 --- /dev/null +++ b/testing/framework-tests/__snapshots__/badge-status-icon-aria-snapshot.yaml @@ -0,0 +1,25 @@ +- main: + - img "Alarm" + - img "Alarm outline" + - img "Alarm pulse" + - img "Alarm pulse outline" + - img "Error" + - img "Error outline" + - img "Error pulse" + - img "Error pulse outline" + - img "Critical" + - img "Critical outline" + - img "Critical pulse" + - img "Critical pulse outline" + - img "Warning" + - img "Warning outline" + - img "Warning pulse" + - img "Warning pulse outline" + - img "Success" + - img "Success outline" + - img "Success pulse" + - img "Success pulse outline" + - img "Info" + - img "Info outline" + - img "Info pulse" + - img "Info pulse outline" diff --git a/testing/framework-tests/tests/generated/axe.ts b/testing/framework-tests/tests/generated/axe.ts index fbad853e355..2f6255f1cba 100644 --- a/testing/framework-tests/tests/generated/axe.ts +++ b/testing/framework-tests/tests/generated/axe.ts @@ -11,6 +11,11 @@ import './application-header-axe.spec.ts'; import './avatar-axe.spec.ts'; import './avatar-image-axe.spec.ts'; import './avatar-initials-axe.spec.ts'; +import './badge-axe.spec.ts'; +import './badge-counter-axe.spec.ts'; +import './badge-dot-axe.spec.ts'; +import './badge-label-axe.spec.ts'; +import './badge-status-icon-axe.spec.ts'; import './blind-axe.spec.ts'; import './blind-header-actions-axe.spec.ts'; import './blind-variants-axe.spec.ts'; diff --git a/testing/framework-tests/tests/generated/badge-axe.spec.ts b/testing/framework-tests/tests/generated/badge-axe.spec.ts new file mode 100644 index 00000000000..734c279c1c7 --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-axe.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness, createAxeBuilder } from '../utils'; + +test('badge - accessibility check', async ({ page }) => { + await page.goto('/preview/badge'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + const accessibilityScanResults = await createAxeBuilder(page).disableRules(['page-has-heading-one']).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/generated/badge-counter-axe.spec.ts b/testing/framework-tests/tests/generated/badge-counter-axe.spec.ts new file mode 100644 index 00000000000..8b049a469bd --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-counter-axe.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness, createAxeBuilder } from '../utils'; + +test('badge-counter - accessibility check', async ({ page }) => { + await page.goto('/preview/badge-counter'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + const accessibilityScanResults = await createAxeBuilder(page).disableRules(['page-has-heading-one']).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/generated/badge-counter.spec.ts b/testing/framework-tests/tests/generated/badge-counter.spec.ts new file mode 100644 index 00000000000..92e186a7541 --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-counter.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness } from '../utils'; + +test('badge-counter', async ({ page }) => { + await page.goto('/preview/badge-counter'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + await expect(page.locator('body')).toMatchAriaSnapshot({ + name: 'badge-counter.aria-snapshot.yaml', + }); +}); diff --git a/testing/framework-tests/tests/generated/badge-dot-axe.spec.ts b/testing/framework-tests/tests/generated/badge-dot-axe.spec.ts new file mode 100644 index 00000000000..a9ce0a254aa --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-dot-axe.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness, createAxeBuilder } from '../utils'; + +test('badge-dot - accessibility check', async ({ page }) => { + await page.goto('/preview/badge-dot'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + const accessibilityScanResults = await createAxeBuilder(page).disableRules(['page-has-heading-one']).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/generated/badge-dot.spec.ts b/testing/framework-tests/tests/generated/badge-dot.spec.ts new file mode 100644 index 00000000000..be3fe4b6426 --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-dot.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness } from '../utils'; + +test('badge-dot', async ({ page }) => { + await page.goto('/preview/badge-dot'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + await expect(page.locator('body')).toMatchAriaSnapshot({ + name: 'badge-dot.aria-snapshot.yaml', + }); +}); diff --git a/testing/framework-tests/tests/generated/badge-label-axe.spec.ts b/testing/framework-tests/tests/generated/badge-label-axe.spec.ts new file mode 100644 index 00000000000..36ceffedbf6 --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-label-axe.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness, createAxeBuilder } from '../utils'; + +test('badge-label - accessibility check', async ({ page }) => { + await page.goto('/preview/badge-label'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + const accessibilityScanResults = await createAxeBuilder(page).disableRules(['page-has-heading-one']).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/generated/badge-label.spec.ts b/testing/framework-tests/tests/generated/badge-label.spec.ts new file mode 100644 index 00000000000..6aa6e8a198c --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-label.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness } from '../utils'; + +test('badge-label', async ({ page }) => { + await page.goto('/preview/badge-label'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + await expect(page.locator('body')).toMatchAriaSnapshot({ + name: 'badge-label.aria-snapshot.yaml', + }); +}); diff --git a/testing/framework-tests/tests/generated/badge-status-icon-axe.spec.ts b/testing/framework-tests/tests/generated/badge-status-icon-axe.spec.ts new file mode 100644 index 00000000000..3e9e139f668 --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-status-icon-axe.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness, createAxeBuilder } from '../utils'; + +test('badge-status-icon - accessibility check', async ({ page }) => { + await page.goto('/preview/badge-status-icon'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + const accessibilityScanResults = await createAxeBuilder(page).disableRules(['page-has-heading-one']).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/generated/badge-status-icon.spec.ts b/testing/framework-tests/tests/generated/badge-status-icon.spec.ts new file mode 100644 index 00000000000..0a3bb9f669a --- /dev/null +++ b/testing/framework-tests/tests/generated/badge-status-icon.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness } from '../utils'; + +test('badge-status-icon', async ({ page }) => { + await page.goto('/preview/badge-status-icon'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + await expect(page.locator('body')).toMatchAriaSnapshot({ + name: 'badge-status-icon.aria-snapshot.yaml', + }); +}); diff --git a/testing/framework-tests/tests/generated/badge.spec.ts b/testing/framework-tests/tests/generated/badge.spec.ts new file mode 100644 index 00000000000..6aad615514b --- /dev/null +++ b/testing/framework-tests/tests/generated/badge.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness } from '../utils'; + +test('badge', async ({ page }) => { + await page.goto('/preview/badge'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + await expect(page.locator('body')).toMatchAriaSnapshot({ + name: 'badge.aria-snapshot.yaml', + }); +}); diff --git a/testing/framework-tests/tests/generated/index.ts b/testing/framework-tests/tests/generated/index.ts index ec437ab17ae..a3d295c9fc0 100644 --- a/testing/framework-tests/tests/generated/index.ts +++ b/testing/framework-tests/tests/generated/index.ts @@ -24,6 +24,16 @@ import './avatar-image.spec.ts'; import './avatar-initials-axe.spec.ts'; import './avatar-initials.spec.ts'; import './avatar.spec.ts'; +import './badge-axe.spec.ts'; +import './badge-counter-axe.spec.ts'; +import './badge-counter.spec.ts'; +import './badge-dot-axe.spec.ts'; +import './badge-dot.spec.ts'; +import './badge-label-axe.spec.ts'; +import './badge-label.spec.ts'; +import './badge-status-icon-axe.spec.ts'; +import './badge-status-icon.spec.ts'; +import './badge.spec.ts'; import './blind-axe.spec.ts'; import './blind-header-actions-axe.spec.ts'; import './blind-header-actions.spec.ts'; diff --git a/testing/framework-tests/tests/generated/test-ids.autogenerated.ts b/testing/framework-tests/tests/generated/test-ids.autogenerated.ts index cb3f5d20d6c..b90cd1f6abd 100644 --- a/testing/framework-tests/tests/generated/test-ids.autogenerated.ts +++ b/testing/framework-tests/tests/generated/test-ids.autogenerated.ts @@ -238,6 +238,11 @@ export const testIds = [ "blind", "blind-variants", "blind-header-actions", + "badge", + "badge-status-icon", + "badge-label", + "badge-dot", + "badge-counter", "avatar", "avatar-initials", "avatar-image", diff --git a/testing/framework-tests/tests/working-with-axe.spec.ts b/testing/framework-tests/tests/working-with-axe.spec.ts index 41e10ec2a1e..edd92d36306 100644 --- a/testing/framework-tests/tests/working-with-axe.spec.ts +++ b/testing/framework-tests/tests/working-with-axe.spec.ts @@ -73,3 +73,10 @@ import './generated/chat-axe.spec'; import './generated/chat-ai-message-axe.spec'; import './generated/chat-input-axe.spec'; import './generated/chat-user-message-axe.spec'; + +// Badge tests +import './generated/badge-axe.spec'; +import './generated/badge-counter-axe.spec'; +import './generated/badge-label-axe.spec'; +import './generated/badge-dot-axe.spec'; +import './generated/badge-status-icon-axe.spec'; diff --git a/testing/framework-tests/tests/working.spec.ts b/testing/framework-tests/tests/working.spec.ts index d1b00632ce3..14876835fcb 100644 --- a/testing/framework-tests/tests/working.spec.ts +++ b/testing/framework-tests/tests/working.spec.ts @@ -73,3 +73,10 @@ import './generated/chat.spec'; import './generated/chat-ai-message.spec'; import './generated/chat-input.spec'; import './generated/chat-user-message.spec'; + +// Badge tests +import './generated/badge.spec'; +import './generated/badge-counter.spec'; +import './generated/badge-label.spec'; +import './generated/badge-dot.spec'; +import './generated/badge-status-icon.spec'; diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-attached-layout-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-attached-layout-1-chromium---classic-dark-linux.png new file mode 100644 index 00000000000..6c1e9335f4f Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-attached-layout-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-attached-layout-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-attached-layout-1-chromium---classic-light-linux.png new file mode 100644 index 00000000000..7af8121f5a9 Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-attached-layout-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-counter-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-counter-1-chromium---classic-dark-linux.png new file mode 100644 index 00000000000..48aad214f1f Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-counter-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-counter-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-counter-1-chromium---classic-light-linux.png new file mode 100644 index 00000000000..363961193bc Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-counter-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-dot-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-dot-1-chromium---classic-dark-linux.png new file mode 100644 index 00000000000..1ffa75a5840 Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-dot-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-dot-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-dot-1-chromium---classic-light-linux.png new file mode 100644 index 00000000000..33edc83cbd2 Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-dot-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-label-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-label-1-chromium---classic-dark-linux.png new file mode 100644 index 00000000000..721c1aa8326 Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-label-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-label-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-label-1-chromium---classic-light-linux.png new file mode 100644 index 00000000000..dcaf5b10a78 Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-label-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-status-icon-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-status-icon-1-chromium---classic-dark-linux.png new file mode 100644 index 00000000000..aa6f8981790 Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-status-icon-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-status-icon-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-status-icon-1-chromium---classic-light-linux.png new file mode 100644 index 00000000000..3f8974a3da0 Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/badge/badge.e2e.ts/badge-status-icon-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/tests/badge/attached-layout/index.html b/testing/visual-testing/tests/badge/attached-layout/index.html new file mode 100644 index 00000000000..f4eab4d59dd --- /dev/null +++ b/testing/visual-testing/tests/badge/attached-layout/index.html @@ -0,0 +1,69 @@ + + + + + + + + Badge attached layout + + + +
+
+ +
Content (inside ix-badge)
+
+
Content (always outside badge)
+
+
+ + + + diff --git a/testing/visual-testing/tests/badge/badge.e2e.ts b/testing/visual-testing/tests/badge/badge.e2e.ts new file mode 100644 index 00000000000..14587f65126 --- /dev/null +++ b/testing/visual-testing/tests/badge/badge.e2e.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { BadgeVisualPage } from './badge.page'; +import { regressionTest } from '@utils/test'; + +regressionTest.describe('badge', () => { + regressionTest('counter', async ({ page }) => { + const badge = new BadgeVisualPage(page); + await badge.gotoCounter(); + await badge.waitForReady(); + await badge.expectSnapshot(); + }); + + regressionTest('label', async ({ page }) => { + const badge = new BadgeVisualPage(page); + await badge.gotoLabel(); + await badge.waitForReady(); + await badge.expectSnapshot(); + }); + + regressionTest('dot', async ({ page }) => { + const badge = new BadgeVisualPage(page); + await badge.gotoDot(); + await badge.waitForReady(); + await badge.expectSnapshot(); + }); + + regressionTest('status-icon', async ({ page }) => { + const badge = new BadgeVisualPage(page); + await badge.gotoStatusIcon(); + await badge.waitForReady(); + await badge.expectSnapshot(); + }); + + regressionTest('attached-layout', async ({ page }) => { + const badge = new BadgeVisualPage(page); + await badge.gotoAttachedLayout(); + await badge.waitForReady(); + await badge.expectSnapshot(); + }); +}); diff --git a/testing/visual-testing/tests/badge/badge.page.ts b/testing/visual-testing/tests/badge/badge.page.ts new file mode 100644 index 00000000000..81dfa96356d --- /dev/null +++ b/testing/visual-testing/tests/badge/badge.page.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { expect, Locator, Page } from '@playwright/test'; + +export const BADGE_COUNTER_ROUTE = 'badge/counter'; +export const BADGE_LABEL_ROUTE = 'badge/label'; +export const BADGE_DOT_ROUTE = 'badge/dot'; +export const BADGE_STATUS_ICON_ROUTE = 'badge/status-icon'; +export const BADGE_ATTACHED_LAYOUT_ROUTE = 'badge/attached-layout'; + +/** + * Page Object Model for ix-badge visual regression tests. + */ +export class BadgeVisualPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get badges(): Locator { + return this.page.locator('ix-badge'); + } + + async gotoCounter(): Promise { + await this.page.goto(BADGE_COUNTER_ROUTE); + } + + async gotoLabel(): Promise { + await this.page.goto(BADGE_LABEL_ROUTE); + } + + async gotoDot(): Promise { + await this.page.goto(BADGE_DOT_ROUTE); + } + + async gotoStatusIcon(): Promise { + await this.page.goto(BADGE_STATUS_ICON_ROUTE); + } + + async gotoAttachedLayout(): Promise { + await this.page.goto(BADGE_ATTACHED_LAYOUT_ROUTE); + } + + async waitForReady(): Promise { + await expect(this.badges).not.toHaveCount(0); + await expect(this.page.locator('ix-badge:not(.hydrated)')).toHaveCount(0); + } + + async expectSnapshot(): Promise { + expect(await this.page.screenshot({ fullPage: true })).toMatchSnapshot(); + } +} diff --git a/testing/visual-testing/tests/badge/counter/index.html b/testing/visual-testing/tests/badge/counter/index.html new file mode 100644 index 00000000000..6e830d94013 --- /dev/null +++ b/testing/visual-testing/tests/badge/counter/index.html @@ -0,0 +1,189 @@ + + + + + + + + Badge counter + + + + +
+

Standalone · filled / outline / border

+
+ + Filled + Outline + Border + + Primary + + + + + Alarm + + + + + Critical + + + + + Warning + + + + + Info + + + + + Success + + + + + Neutral + + + + + Custom + + + +
+
+ +
+

Counts · overflow · max-width

+
+ + + + + + + + +
+
+ +
+

Attached · anchors · position · offset

+
+ + Review + + + + + + + + + Bottom after + + + Offset + +
+
+ + + + diff --git a/testing/visual-testing/tests/badge/dot/index.html b/testing/visual-testing/tests/badge/dot/index.html new file mode 100644 index 00000000000..ed60bf7b76b --- /dev/null +++ b/testing/visual-testing/tests/badge/dot/index.html @@ -0,0 +1,163 @@ + + + + + + + + Badge dot + + + + +
+

Standalone · filled / outline / border

+
+ + Filled + Outline + Border + + Primary + + + + + Alarm + + + + + Critical + + + + + Warning + + + + + Info + + + + + Success + + + + + Neutral + + + + + Custom + + + +
+
+ +
+

Attached · anchors · position · offset

+
+ + Notifications + + + + + + + + + Bottom after + + + Offset + +
+
+ + + + diff --git a/testing/visual-testing/tests/badge/label/index.html b/testing/visual-testing/tests/badge/label/index.html new file mode 100644 index 00000000000..4a2be72226e --- /dev/null +++ b/testing/visual-testing/tests/badge/label/index.html @@ -0,0 +1,210 @@ + + + + + + + + Badge label + + + + +
+

Standalone · filled / outline / border

+
+ + Filled + Outline + Border + + Primary + + + + + Alarm + + + + + Critical + + + + + Warning + + + + + Info + + + + + Success + + + + + Neutral + + + + + Custom + + + +
+
+ +
+

Icon · align-left · max-width

+
+ + + + + +
+
+ +
+

Attached · anchors · position · offset

+
+ + What's new + + + + + + + + + Bottom after + + + Offset + +
+
+ + + + diff --git a/testing/visual-testing/tests/badge/status-icon/index.html b/testing/visual-testing/tests/badge/status-icon/index.html new file mode 100644 index 00000000000..1f6924822e9 --- /dev/null +++ b/testing/visual-testing/tests/badge/status-icon/index.html @@ -0,0 +1,160 @@ + + + + + + + + Badge status-icon + + + + +
+

Standalone · filled / outline

+
+ + Filled + Outline + + Alarm + + + + Error + + + + Critical + + + + Warning + + + + Success + + + + Info + + +
+
+ +
+

Attached · anchors · position · offset

+
+ + Alarm + + + + + + + + + Bottom after + + + Offset + +
+
+ + + +