Skip to content

Commit 0b96d25

Browse files
authored
feat(ui5-avatar-badge): add new AvatarBadge component (#12938)
* feat(ui5-avatar-badge): add new AvatarBadge component Add `ui5-avatar-badge` component for visual affordance on avatars. The badge displays icons with different value states to indicate status or notifications. Key features: - Five value states (None, Positive, Critical, Negative, Information) - RTL support with CSS logical properties - Works in badge slot of `ui5-avatar` Updated Avatar component to reference `ui5-avatar-badge` in badge slot documentation. Documentation structured following Button/ButtonBadge pattern with dedicated AvatarBadge page marked as new component. Fixes internal issue with avatar badge implementation. Jira: BGSOFUIPIRIN-6975 Fixes #12396 * refactor(ui5-avatar-badge): apply code review feedback - Move default styles to :host selector - Remove redundant @SInCE tags from properties - Remove CSS header comment - Move icon styles to end of file for better organization * test(ui5-avatar): add AvatarBadge tests and migrate from Tag - Migrate disabled interaction test from ui5-tag to ui5-avatar-badge - Fix typo in test ID (diabled -> disabled) - Add comprehensive badge tests: - Badge rendering with icons - All five value states - Badge size for each avatar size (XS, S, M, L, XL) - Badge icon size for each avatar size - Add required icon imports (accept, message-error, information, ai) * chore: update Avatar badge documentation * chore(ui5-avatar): improve AvatarBadge examples and styling * feat(ui5-avatar-badge): hide badge when icon is invalid Badge now validates icon on render and hides itself when icon is empty or not found in the icon registry. * test(ui5-avatar-badge): add test for invalid icon handling Add test to verify AvatarBadge correctly hides itself when provided with empty, missing, or non-existent icon names, and shows when valid. Test covers: - Empty icon string ("") - Non-existent icon name ("non-existent-icon-xyz") - No icon property set - Valid icon ("accept") as reference Uses direct DOM manipulation (document.createElement) instead of cy.mount() because the Cypress UI5 plugin validates that mounted components have shadow DOM content. Invalid badges intentionally render empty shadow DOM (by design), which causes the plugin's validation to fail. Direct DOM manipulation bypasses this limitation while still allowing us to test the component behavior correctly. Elements are cleaned up after test to prevent DOM pollution.
1 parent fb32981 commit 0b96d25

File tree

20 files changed

+783
-213
lines changed

20 files changed

+783
-213
lines changed

packages/main/cypress/specs/Avatar.cy.tsx

Lines changed: 207 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import Avatar from "../../src/Avatar.js";
2-
import Tag from "../../src/Tag.js";
3-
import Icon from "../../src/Icon.js";
2+
import AvatarBadge from "../../src/AvatarBadge.js";
43
import "@ui5/webcomponents-icons/dist/supplier.js";
54
import "@ui5/webcomponents-icons/dist/alert.js";
65
import "@ui5/webcomponents-icons/dist/person-placeholder.js";
6+
import "@ui5/webcomponents-icons/dist/accelerated.js";
7+
import "@ui5/webcomponents-icons/dist/accept.js";
8+
import "@ui5/webcomponents-icons/dist/message-error.js";
9+
import "@ui5/webcomponents-icons/dist/information.js";
10+
import "@ui5/webcomponents-icons/dist/ai.js";
711

812
describe("Accessibility", () => {
913
it("checks if initials of avatar are correctly announced", () => {
@@ -103,10 +107,8 @@ describe("Accessibility", () => {
103107
it("doesn't fire ui5-click event, when disabled property is set", () => {
104108
cy.mount(
105109
<div>
106-
<Avatar interactive disabled initials="JD" id="diabled-avatar" onClick={increment}>
107-
<Tag slot="badge">
108-
<Icon slot="icon" name="accelerated"></Icon>
109-
</Tag>
110+
<Avatar interactive disabled initials="JD" id="disabled-avatar" onClick={increment}>
111+
<AvatarBadge slot="badge" icon="accelerated"></AvatarBadge>
110112
</Avatar>
111113
<input value="0" id="click-event" />
112114
</div>
@@ -116,7 +118,7 @@ describe("Accessibility", () => {
116118
const input = document.getElementById("click-event") as HTMLInputElement;
117119
input.value = "1";
118120
}
119-
cy.get("#diabled-avatar").realClick();
121+
cy.get("#disabled-avatar").realClick();
120122
cy.get("#click-event").should("have.value", "0");
121123
});
122124
});
@@ -566,3 +568,201 @@ describe("Avatar Rendering and Interaction", () => {
566568
.should("have.been.calledOnce");
567569
});
568570
});
571+
572+
describe("Avatar with Badge", () => {
573+
it("renders badge with icon", () => {
574+
cy.mount(
575+
<Avatar id="avatar-with-badge-icon" initials="AB" size="M">
576+
<AvatarBadge slot="badge" icon="accept" valueState="Positive"></AvatarBadge>
577+
</Avatar>
578+
);
579+
580+
// Verify badge is rendered
581+
cy.get("#avatar-with-badge-icon")
582+
.find("[ui5-avatar-badge]")
583+
.should("exist");
584+
585+
// Verify icon inside badge is rendered
586+
cy.get("#avatar-with-badge-icon [ui5-avatar-badge]")
587+
.shadow()
588+
.find(".ui5-avatar-badge-icon")
589+
.should("exist")
590+
.and("have.attr", "name", "accept");
591+
});
592+
593+
it("renders badge with different value states", () => {
594+
cy.mount(
595+
<>
596+
<Avatar id="badge-none" initials="AB" size="M">
597+
<AvatarBadge slot="badge" icon="ai" valueState="None"></AvatarBadge>
598+
</Avatar>
599+
<Avatar id="badge-positive" initials="CD" size="M">
600+
<AvatarBadge slot="badge" icon="accept" valueState="Positive"></AvatarBadge>
601+
</Avatar>
602+
<Avatar id="badge-critical" initials="EF" size="M">
603+
<AvatarBadge slot="badge" icon="alert" valueState="Critical"></AvatarBadge>
604+
</Avatar>
605+
<Avatar id="badge-negative" initials="GH" size="M">
606+
<AvatarBadge slot="badge" icon="message-error" valueState="Negative"></AvatarBadge>
607+
</Avatar>
608+
<Avatar id="badge-information" initials="IJ" size="M">
609+
<AvatarBadge slot="badge" icon="information" valueState="Information"></AvatarBadge>
610+
</Avatar>
611+
</>
612+
);
613+
614+
// Check that all badges are rendered in badge slot
615+
cy.get("#badge-none").find("[ui5-avatar-badge]").should("exist");
616+
cy.get("#badge-positive").find("[ui5-avatar-badge]").should("exist");
617+
cy.get("#badge-critical").find("[ui5-avatar-badge]").should("exist");
618+
cy.get("#badge-negative").find("[ui5-avatar-badge]").should("exist");
619+
cy.get("#badge-information").find("[ui5-avatar-badge]").should("exist");
620+
621+
// Verify value states are applied
622+
cy.get("#badge-none [ui5-avatar-badge]").should("have.attr", "value-state", "None");
623+
cy.get("#badge-positive [ui5-avatar-badge]").should("have.attr", "value-state", "Positive");
624+
cy.get("#badge-critical [ui5-avatar-badge]").should("have.attr", "value-state", "Critical");
625+
cy.get("#badge-negative [ui5-avatar-badge]").should("have.attr", "value-state", "Negative");
626+
cy.get("#badge-information [ui5-avatar-badge]").should("have.attr", "value-state", "Information");
627+
});
628+
629+
it("badge has correct size for each avatar size", () => {
630+
cy.mount(
631+
<>
632+
<Avatar id="avatar-xs" initials="XS" size="XS">
633+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
634+
</Avatar>
635+
<Avatar id="avatar-s" initials="S" size="S">
636+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
637+
</Avatar>
638+
<Avatar id="avatar-m" initials="M" size="M">
639+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
640+
</Avatar>
641+
<Avatar id="avatar-l" initials="L" size="L">
642+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
643+
</Avatar>
644+
<Avatar id="avatar-xl" initials="XL" size="XL">
645+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
646+
</Avatar>
647+
</>
648+
);
649+
650+
// Check that badges are rendered for all avatar sizes
651+
cy.get("#avatar-xs [ui5-avatar-badge]").should("exist");
652+
cy.get("#avatar-s [ui5-avatar-badge]").should("exist");
653+
cy.get("#avatar-m [ui5-avatar-badge]").should("exist");
654+
cy.get("#avatar-l [ui5-avatar-badge]").should("exist");
655+
cy.get("#avatar-xl [ui5-avatar-badge]").should("exist");
656+
657+
// Verify XS/S/M size (default badge size: 1.125rem = 18px)
658+
cy.get("#avatar-xs [ui5-avatar-badge]").should("have.css", "width", "18px");
659+
cy.get("#avatar-s [ui5-avatar-badge]").should("have.css", "width", "18px");
660+
cy.get("#avatar-m [ui5-avatar-badge]").should("have.css", "width", "18px");
661+
662+
// Verify L size (1.25rem = 20px)
663+
cy.get("#avatar-l [ui5-avatar-badge]").should("have.css", "width", "20px");
664+
665+
// Verify XL size (1.75rem = 28px)
666+
cy.get("#avatar-xl [ui5-avatar-badge]").should("have.css", "width", "28px");
667+
});
668+
669+
it("badge icon has correct size for each avatar size", () => {
670+
cy.mount(
671+
<>
672+
<Avatar id="avatar-xs-icon" initials="XS" size="XS">
673+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
674+
</Avatar>
675+
<Avatar id="avatar-s-icon" initials="S" size="S">
676+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
677+
</Avatar>
678+
<Avatar id="avatar-m-icon" initials="M" size="M">
679+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
680+
</Avatar>
681+
<Avatar id="avatar-l-icon" initials="L" size="L">
682+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
683+
</Avatar>
684+
<Avatar id="avatar-xl-icon" initials="XL" size="XL">
685+
<AvatarBadge slot="badge" icon="accept"></AvatarBadge>
686+
</Avatar>
687+
</>
688+
);
689+
690+
// Verify XS/S/M icon size (default: 0.75rem = 12px)
691+
cy.get("#avatar-xs-icon [ui5-avatar-badge]")
692+
.shadow()
693+
.find(".ui5-avatar-badge-icon")
694+
.should("have.css", "width", "12px");
695+
cy.get("#avatar-s-icon [ui5-avatar-badge]")
696+
.shadow()
697+
.find(".ui5-avatar-badge-icon")
698+
.should("have.css", "width", "12px");
699+
cy.get("#avatar-m-icon [ui5-avatar-badge]")
700+
.shadow()
701+
.find(".ui5-avatar-badge-icon")
702+
.should("have.css", "width", "12px");
703+
704+
// Verify L icon size (0.875rem = 14px)
705+
cy.get("#avatar-l-icon [ui5-avatar-badge]")
706+
.shadow()
707+
.find(".ui5-avatar-badge-icon")
708+
.should("have.css", "width", "14px");
709+
710+
// Verify XL icon size (1rem = 16px)
711+
cy.get("#avatar-xl-icon [ui5-avatar-badge]")
712+
.shadow()
713+
.find(".ui5-avatar-badge-icon")
714+
.should("have.css", "width", "16px");
715+
});
716+
717+
it("hides badge when icon is invalid and shows when valid", () => {
718+
// Test all invalid cases and valid case in one test using direct DOM manipulation
719+
cy.document().then(doc => {
720+
// Create and test empty icon badge
721+
const badgeEmpty = doc.createElement("ui5-avatar-badge") as AvatarBadge;
722+
badgeEmpty.icon = "";
723+
doc.body.appendChild(badgeEmpty);
724+
725+
// Create and test invalid icon badge
726+
const badgeInvalid = doc.createElement("ui5-avatar-badge") as AvatarBadge;
727+
badgeInvalid.icon = "non-existent-icon-xyz";
728+
doc.body.appendChild(badgeInvalid);
729+
730+
// Create and test no icon badge
731+
const badgeNoIcon = doc.createElement("ui5-avatar-badge") as AvatarBadge;
732+
doc.body.appendChild(badgeNoIcon);
733+
734+
// Create and test valid icon badge
735+
const badgeValid = doc.createElement("ui5-avatar-badge") as AvatarBadge;
736+
badgeValid.icon = "accept";
737+
doc.body.appendChild(badgeValid);
738+
739+
// Wait for components to render
740+
cy.wait(200).then(() => {
741+
// Test empty icon
742+
expect(badgeEmpty.hasAttribute("invalid"), "empty icon should have invalid attr").to.be.true;
743+
expect(getComputedStyle(badgeEmpty).display, "empty icon should be hidden").to.equal("none");
744+
745+
// Test invalid icon
746+
expect(badgeInvalid.hasAttribute("invalid"), "invalid icon should have invalid attr").to.be.true;
747+
expect(getComputedStyle(badgeInvalid).display, "invalid icon should be hidden").to.equal("none");
748+
749+
// Test no icon
750+
expect(badgeNoIcon.hasAttribute("invalid"), "no icon should have invalid attr").to.be.true;
751+
expect(getComputedStyle(badgeNoIcon).display, "no icon should be hidden").to.equal("none");
752+
753+
// Test valid icon
754+
expect(badgeValid.hasAttribute("invalid"), "valid icon should NOT have invalid attr").to.be.false;
755+
expect(getComputedStyle(badgeValid).display, "valid icon should be visible").to.not.equal("none");
756+
const icon = badgeValid.shadowRoot?.querySelector(".ui5-avatar-badge-icon");
757+
expect(icon, "valid icon should have icon in shadow DOM").to.exist;
758+
expect(icon?.getAttribute("name")).to.equal("accept");
759+
760+
// Cleanup: remove elements from DOM
761+
badgeEmpty.remove();
762+
badgeInvalid.remove();
763+
badgeNoIcon.remove();
764+
badgeValid.remove();
765+
});
766+
});
767+
});
768+
});

packages/main/src/Avatar.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,7 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
242242
* Defines the optional badge that will be used for visual affordance.
243243
*
244244
* **Note:** While the slot allows for custom badges, to achieve
245-
* the Fiori design, you can use the `ui5-tag` with `ui5-icon`
246-
* in the corresponding `icon` slot, without text nodes.
245+
* the Fiori design, use the `ui5-avatar-badge` component.
247246
* @public
248247
* @since 1.7.0
249248
*/

packages/main/src/AvatarBadge.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
2+
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
3+
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
4+
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
5+
import { getIconDataSync } from "@ui5/webcomponents-base/dist/asset-registries/Icons.js";
6+
7+
// Template
8+
import AvatarBadgeTemplate from "./AvatarBadgeTemplate.js";
9+
10+
// Styles
11+
import AvatarBadgeCss from "./generated/themes/AvatarBadge.css.js";
12+
13+
import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
14+
15+
/**
16+
* @class
17+
* ### Overview
18+
*
19+
* The `ui5-avatar-badge` component is used to display a badge on top of `ui5-avatar` component.
20+
* The badge can display an icon and supports different value states for visual affordance.
21+
*
22+
* ### Usage
23+
*
24+
* The badge should be used as a child element of `ui5-avatar` in the `badge` slot.
25+
*
26+
* ```html
27+
* <ui5-avatar>
28+
* <ui5-avatar-badge icon="edit" slot="badge"></ui5-avatar-badge>
29+
* </ui5-avatar>
30+
* ```
31+
*
32+
* ### Keyboard Handling
33+
*
34+
* The badge does not receive keyboard focus.
35+
*
36+
* ### ES6 Module Import
37+
* `import "@ui5/webcomponents/dist/AvatarBadge.js";`
38+
*
39+
* @constructor
40+
* @extends UI5Element
41+
* @since 2.19.0
42+
* @public
43+
*/
44+
@customElement({
45+
tag: "ui5-avatar-badge",
46+
renderer: jsxRenderer,
47+
styles: AvatarBadgeCss,
48+
template: AvatarBadgeTemplate,
49+
})
50+
class AvatarBadge extends UI5Element {
51+
/**
52+
* Defines the icon name to be displayed inside the badge.
53+
*
54+
* **Note:** You should import the desired icon first, then use its name as "icon".
55+
*
56+
* `import "@ui5/webcomponents-icons/dist/{icon_name}.js"`
57+
*
58+
* @default undefined
59+
* @public
60+
*/
61+
@property()
62+
icon?: string;
63+
64+
/**
65+
* Defines the value state of the badge, which determines its styling.
66+
*
67+
* Available options:
68+
* - `None` (default) - Standard appearance
69+
* - `Positive` - Green, used for success/approved states
70+
* - `Critical` - Orange, used for warning states
71+
* - `Negative` - Red, used for error/rejected states
72+
* - `Information` - Blue, used for informational states
73+
*
74+
* @default "None"
75+
* @public
76+
*/
77+
@property()
78+
valueState: `${ValueState}` = ValueState.None;
79+
80+
/**
81+
* @private
82+
*/
83+
@property({ type: Boolean })
84+
invalid = false;
85+
86+
onBeforeRendering() {
87+
this.invalid = !this.icon || !getIconDataSync(this.icon);
88+
}
89+
}
90+
91+
AvatarBadge.define();
92+
93+
export default AvatarBadge;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type AvatarBadge from "./AvatarBadge.js";
2+
import Icon from "./Icon.js";
3+
4+
export default function AvatarBadgeTemplate(this: AvatarBadge) {
5+
return (
6+
<>
7+
{!this.invalid && (
8+
<Icon
9+
name={this.icon}
10+
class="ui5-avatar-badge-icon"
11+
mode="Decorative"
12+
></Icon>
13+
)}
14+
</>
15+
);
16+
}

packages/main/src/bundle.esm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { getAllRegisteredTags } from "@ui5/webcomponents-base/dist/CustomElement
3030
// setDefaultIconCollection("sap_fiori_3", "my-custom-icons");
3131

3232
import Avatar from "./Avatar.js";
33+
import AvatarBadge from "./AvatarBadge.js";
3334
import AvatarGroup from "./AvatarGroup.js";
3435
import Bar from "./Bar.js";
3536
import Breadcrumbs from "./Breadcrumbs.js";

0 commit comments

Comments
 (0)