Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions packages/main/cypress/specs/Avatar.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe("Accessibility", () => {
it("checks if initials of avatar are correctly announced", () => {
const INITIALS = "XS";

cy.mount(<Avatar id="interactive-avatar" initials={INITIALS} interactive></Avatar>);
cy.mount(<Avatar id="interactive-avatar" initials={INITIALS} mode="Interactive"></Avatar>);

// Store the expected label to compare against
const expectedLabel = `Avatar ${INITIALS}`;
Expand All @@ -34,7 +34,7 @@ describe("Accessibility", () => {
<Avatar
id="interactive-info"
initials={INITIALS}
interactive
mode="Interactive"
accessibleName={customLabel}
accessibilityAttributes={{hasPopup}}
></Avatar>
Expand Down Expand Up @@ -77,7 +77,7 @@ describe("Accessibility", () => {
<Avatar
id="default-label-info"
initials={INITIALS}
interactive
mode="Interactive"
></Avatar>
);

Expand Down Expand Up @@ -121,6 +121,98 @@ describe("Accessibility", () => {
cy.get("#disabled-avatar").realClick();
cy.get("#click-event").should("have.value", "0");
});

it("should support mode='Decorative' with aria-hidden and role='presentation'", () => {
cy.mount(
<Avatar
id="decorative-avatar"
initials="AB"
mode="Decorative"
></Avatar>
);

cy.get("#decorative-avatar")
.shadow()
.find(".ui5-avatar-root")
.should("have.attr", "role", "presentation")
.should("have.attr", "aria-hidden", "true")
.should("not.have.attr", "tabindex");
});

it("should support mode='Interactive' with role='button' and focusable", () => {
cy.mount(
<Avatar
id="interactive-mode-avatar"
initials="IJ"
mode="Interactive"
></Avatar>
);

cy.get("#interactive-mode-avatar")
.shadow()
.find(".ui5-avatar-root")
.should("have.attr", "role", "button")
.should("have.attr", "tabindex", "0")
.should("not.have.attr", "aria-hidden");
});

it("deprecated interactive property still allows click events but doesn't affect accessibility", () => {
cy.mount(
<div>
<Avatar interactive initials="JD" id="deprecated-interactive" onClick={increment}></Avatar>
<input value="0" id="click-event-deprecated" />
</div>
);

function increment() {
const input = document.getElementById("click-event-deprecated") as HTMLInputElement;
input.value = "1";
}

// Should have role="img" (not button) since mode is not Interactive
cy.get("#deprecated-interactive")
.shadow()
.find(".ui5-avatar-root")
.should("have.attr", "role", "img")
.should("not.have.attr", "tabindex");

// But should still fire click event
cy.get("#deprecated-interactive").realClick();
cy.get("#click-event-deprecated").should("have.value", "1");
});

it("should use role='img' in Image mode when not interactive", () => {
cy.mount(
<Avatar
id="image-mode-avatar"
initials="EF"
mode="Image"
></Avatar>
);

cy.get("#image-mode-avatar")
.shadow()
.find(".ui5-avatar-root")
.should("have.attr", "role", "img")
.should("not.have.attr", "aria-hidden");
});

it("should use role='button' in Interactive mode", () => {
cy.mount(
<Avatar
id="interactive-mode"
initials="GH"
mode="Interactive"
></Avatar>
);

cy.get("#interactive-mode")
.shadow()
.find(".ui5-avatar-root")
.should("have.attr", "role", "button")
.should("have.attr", "tabindex", "0")
.should("not.have.attr", "aria-hidden");
});
});

describe("Fallback Logic", () => {
Expand Down
50 changes: 44 additions & 6 deletions packages/main/src/Avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type Icon from "./Icon.js";
import AvatarSize from "./types/AvatarSize.js";
import type AvatarShape from "./types/AvatarShape.js";
import type AvatarColorScheme from "./types/AvatarColorScheme.js";
import AvatarMode from "./types/AvatarMode.js";

// Icon
import "@ui5/webcomponents-icons/dist/employee.js";
Expand All @@ -48,7 +49,7 @@ type AvatarAccessibilityAttributes = Pick<AccessibilityAttributes, "hasPopup">;
*
* ### Keyboard Handling
*
* - [Space] / [Enter] or [Return] - Fires the `click` event if the `interactive` property is set to true.
* - [Space] / [Enter] or [Return] - Fires the `click` event if the `mode` is set to `Interactive` or the deprecated `interactive` property is set to true.
* - [Shift] - If [Space] is pressed, pressing [Shift] releases the component without triggering the click event.
*
* ### ES6 Module Import
Expand Down Expand Up @@ -92,16 +93,38 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
disabled = false;

/**
* Defines if the avatar is interactive (focusable and pressable).
* Defines if the avatar fires click events.
*
* **Note:** This property only controls event firing behavior.
* It does not affect the component's accessibility attributes.
* To make the avatar interactive with proper accessibility (role="button", focusable),
* use `mode="Interactive"` instead.
*
* **Note:** This property won't have effect if the `disabled`
* property is set to `true`.
* @default false
* @public
* @deprecated Since version 2.19. Use the `mode` property with value `Interactive` instead.
* For accessibility and visual affordance (role="button", focusable), set `mode="Interactive"`.
* This property now only controls whether click events are fired.
*/
@property({ type: Boolean })
interactive = false;

/**
* Defines the mode of the component.
*
* **Note:**
* - `Image` (default) - renders with role="img"
* - `Decorative` - renders with role="presentation" and aria-hidden="true", making it purely decorative
* - `Interactive` - renders with role="button", focusable (tabindex="0"), and supports keyboard interaction
* @default "Image"
* @public
* @since 2.19
*/
@property()
mode: `${AvatarMode}` = "Image";

/**
* Defines the name of the UI5 Icon, that will be displayed.
*
Expand Down Expand Up @@ -273,7 +296,11 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
if (this.forcedTabIndex) {
return parseInt(this.forcedTabIndex);
}
return this._interactive ? 0 : undefined;
// Interactive mode makes the avatar focusable
if (this.mode === AvatarMode.Interactive) {
return 0;
}
return undefined;
}

/**
Expand All @@ -297,7 +324,18 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
}

get _role() {
return this._interactive ? "button" : "img";
switch (this.mode) {
case AvatarMode.Interactive:
return "button";
case AvatarMode.Decorative:
return "presentation";
default:
return "img";
}
}

get effectiveAriaHidden() {
return this.mode === AvatarMode.Decorative ? "true" : undefined;
}

get _ariaHasPopup() {
Expand Down Expand Up @@ -429,7 +467,7 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
_getAriaHasPopup() {
const ariaHaspopup = this.accessibilityAttributes.hasPopup;

if (!this._interactive || !ariaHaspopup) {
if (this.mode !== AvatarMode.Interactive || !ariaHaspopup) {
return;
}

Expand Down Expand Up @@ -500,7 +538,7 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
get accessibilityInfo() {
return {
role: this._role as AriaRole,
type: this.interactive ? Avatar.i18nBundle.getText(AVATAR_TYPE_BUTTON) : Avatar.i18nBundle.getText(AVATAR_TYPE_IMAGE),
type: this.mode === AvatarMode.Interactive ? Avatar.i18nBundle.getText(AVATAR_TYPE_BUTTON) : Avatar.i18nBundle.getText(AVATAR_TYPE_IMAGE),
description: this.accessibleNameText,
disabled: this.disabled,
};
Expand Down
1 change: 1 addition & 0 deletions packages/main/src/AvatarTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default function AvatarTemplate(this: Avatar) {
tabindex={this.tabindex}
data-sap-focus-ref
role={this._role}
aria-hidden={this.effectiveAriaHidden}
aria-haspopup={this._ariaHasPopup}
aria-label={this.accessibleNameText}
onKeyUp={this._onkeyup}
Expand Down
33 changes: 33 additions & 0 deletions packages/main/src/types/AvatarMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Different Avatar modes.
* @public
*/
enum AvatarMode {
/**
* Image mode (by default).
* Configures the component to internally render role="img".
* @public
* @since 2.19
*/
Image = "Image",

/**
* Decorative mode.
* Configures the component to internally render role="presentation" and aria-hidden="true",
* making it purely decorative without semantic content or interactivity.
* @public
* @since 2.19
*/
Decorative = "Decorative",

/**
* Interactive mode.
* Configures the component to internally render role="button".
* This mode also supports focus and enables keyboard interaction.
* @public
* @since 2.19
*/
Interactive = "Interactive",
}

export default AvatarMode;
26 changes: 25 additions & 1 deletion packages/main/test/pages/Avatar.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,34 @@ <h3>Avatar - interactive</h3>
<ui5-input id="click-event" value="0"></ui5-input>

<br>
<ui5-avatar id="myInteractiveAvatar" interactive initials="L" size="L"></ui5-avatar>
<ui5-avatar id="myInteractiveAvatar" mode="Interactive" initials="L" size="L"></ui5-avatar>
<ui5-input id="click-event-2"></ui5-input>
</section>

<section>
<h3>Avatar - mode="Decorative"</h3>
<p>Decorative avatars are purely visual with role="presentation" and aria-hidden="true"</p>
<ui5-avatar mode="Decorative" initials="AB" size="S"></ui5-avatar>
<ui5-avatar mode="Decorative" icon="employee" size="S"></ui5-avatar>
<ui5-avatar mode="Decorative" size="S">
<img src="./img/John_Miller.png" alt="John Miller">
</ui5-avatar>

<h3>Avatar - mode="Image" (default)</h3>
<p>Image mode avatars have role="img"</p>
<ui5-avatar mode="Image" initials="CD" size="S"></ui5-avatar>

<h3>Avatar - mode="Interactive"</h3>
<p>Interactive mode avatars have role="button", are focusable, and support keyboard interaction</p>
<ui5-avatar mode="Interactive" initials="EF" size="S"></ui5-avatar>

<h3>Avatar - Deprecated interactive property</h3>
<p>The interactive property (deprecated) fires events but doesn't affect accessibility</p>
<ui5-avatar interactive initials="GH" size="S"></ui5-avatar>
<p>For proper accessibility, use mode="Interactive" instead</p>
<ui5-avatar mode="Interactive" initials="IJ" size="S"></ui5-avatar>
</section>

<section>
<h3>Avatar Badge - All Sizes</h3>
<div style="display: flex; flex-direction: row; align-items: end; column-gap: 0.5rem;">
Expand Down
10 changes: 8 additions & 2 deletions packages/website/docs/_components_pages/main/Avatar/Avatar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ slug: ../Avatar

import Basic from "../../../_samples/main/Avatar/Basic/Basic.md";
import Interactive from "../../../_samples/main/Avatar/Interactive/Interactive.md";
import Decorative from "../../../_samples/main/Avatar/Decorative/Decorative.md";
import WithBadge from "../../../_samples/main/Avatar/WithBadge/WithBadge.md";
import ColorSchemes from "../../../_samples/main/Avatar/ColorSchemes/ColorSchemes.md";
import Sizes from "../../../_samples/main/Avatar/Sizes/Sizes.md";
Expand Down Expand Up @@ -31,11 +32,16 @@ The Avatar comes in several sizes (S to XL).

<Sizes />

### Interactive
The Avatar can be interactive, e.g. responds to hover, focus and press.
### Interactive Mode
The Avatar can be set to interactive mode with `mode="Interactive"`, making it focusable with role="button" and enabling keyboard interaction. This is the recommended way to make an avatar interactive.

<Interactive />

### Decorative Mode
The Avatar can be set to decorative mode with `mode="Decorative"`, making it purely visual without semantic content (role="presentation", aria-hidden="true"). This is useful when the avatar is used for visual purposes only and shouldn't be announced by screen readers.

<Decorative />

### Color Schemes
The Avatar can be displayed in multiple color schemes.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import html from '!!raw-loader!./sample.html';
import js from '!!raw-loader!./main.js';

<Editor html={html} js={js} />
2 changes: 2 additions & 0 deletions packages/website/docs/_samples/main/Avatar/Decorative/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import "@ui5/webcomponents/dist/Label.js";
import "@ui5/webcomponents/dist/Avatar.js";
43 changes: 43 additions & 0 deletions packages/website/docs/_samples/main/Avatar/Decorative/sample.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!-- playground-fold -->
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sample</title>
</head>

<body style="background-color: var(--sapBackgroundColor)">
<!-- playground-fold-end -->

<style>
.example-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
</style>

<div class="example-row">
<ui5-avatar mode="Decorative" initials="AB"></ui5-avatar>
<ui5-label>Decorative avatar with initials - not in accessibility tree</ui5-label>
</div>

<div class="example-row">
<ui5-avatar mode="Image" initials="CD"></ui5-avatar>
<ui5-label>Image mode avatar (default) - announced as "Avatar CD"</ui5-label>
</div>

<div class="example-row">
<ui5-avatar mode="Interactive" initials="EF"></ui5-avatar>
<ui5-label>Interactive mode - focusable with role="button"</ui5-label>
</div>

<!-- playground-fold -->
<script type="module" src="main.js"></script>
</body>

</html>
<!-- playground-fold-end -->
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<body style="background-color: var(--sapBackgroundColor)">
<!-- playground-fold-end -->

<ui5-avatar id="avt" interactive initials="FJ"></ui5-avatar>
<ui5-avatar id="avt" mode="Interactive" initials="FJ"></ui5-avatar>

<ui5-label id="lbl"></ui5-label>
<!-- playground-fold -->
Expand Down
Loading