Skip to content

Commit 30777dc

Browse files
authored
feat(ui5-ai-button): add customizable accessibility attributes to AI Button and SplitButton (#11929)
Introduced accessibilityAttributes prop for Button and SplitButton to configure ARIA and tooltip attributes on root and arrow button elements. Updated templates to apply these attributes and ensure correct accessibility markup.
1 parent c4a416b commit 30777dc

File tree

14 files changed

+340
-52
lines changed

14 files changed

+340
-52
lines changed

packages/ai/cypress/specs/Button.cy.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,73 @@ describe("Initial rendering", () => {
1212
);
1313
});
1414
});
15+
16+
describe("Accessibility", () => {
17+
it("should set correct tooltip to right text button", () => {
18+
cy.mount(
19+
<Button>
20+
<ButtonState name="generate" text="Generate" icon="ai">Click me</ButtonState>
21+
<ButtonState name="revise" text="Revise" icon="stop">Click me</ButtonState>
22+
</Button>
23+
);
24+
25+
cy.get("[ui5-ai-button]")
26+
.ui5AIButtonCheckAttributeInTextButton("tooltip", "Generate with Artificial Intelligence");
27+
});
28+
29+
it("should set correct aria-haspopup to SplitButton root element", () => {
30+
cy.mount(
31+
<Button accessibilityAttributes={{ root: { hasPopup: "menu" } }}>
32+
<ButtonState name="generate" text="Generate" icon="ai">Click me</ButtonState>
33+
</Button>
34+
);
35+
36+
cy.get("[ui5-ai-button]")
37+
.ui5AIButtonCheckAttributeSplitButtonRoot("aria-haspopup", "menu");
38+
});
39+
40+
it("should set correct aria-roledescription to SplitButton root element", () => {
41+
cy.mount(
42+
<Button accessibilityAttributes={{ root: { roleDescription: "Open Menu" } }}>
43+
<ButtonState name="generate" text="Generate" icon="ai">Click me</ButtonState>
44+
</Button>
45+
);
46+
47+
cy.get("[ui5-ai-button]")
48+
.ui5AIButtonCheckAttributeSplitButtonRoot("aria-roledescription", "Open Menu");
49+
});
50+
51+
it("should set correct aria-haspopup to arrow button if shown", () => {
52+
cy.mount(
53+
<Button accessibilityAttributes={{ arrowButton: { hasPopup: "menu", expanded: false } }}>
54+
<ButtonState name="generate" text="Generate" icon="ai" showArrowButton={true}>Click me</ButtonState>
55+
</Button>
56+
);
57+
58+
cy.get("[ui5-ai-button]")
59+
.ui5AIButtonCheckAttributeInArrowButton("aria-haspopup", "menu");
60+
});
61+
62+
it("should set correct aria attributes with default values when not provided", () => {
63+
cy.mount(
64+
<Button>
65+
<ButtonState name="generate" text="Generate" icon="ai" showArrowButton={true}>Click me</ButtonState>
66+
</Button>
67+
);
68+
69+
cy.get("[ui5-ai-button]")
70+
.as("button");
71+
72+
cy.get("@button")
73+
.ui5AIButtonCheckAttributeSplitButtonRoot("aria-haspopup", "false");
74+
75+
cy.get("@button")
76+
.ui5AIButtonCheckAttributeSplitButtonRoot("aria-roledescription", "Split Button");
77+
78+
cy.get("@button")
79+
.ui5AIButtonCheckAttributeInArrowButton("aria-haspopup", "menu");
80+
81+
cy.get("@button")
82+
.ui5AIButtonCheckAttributeInArrowButton("aria-expanded", "false");
83+
});
84+
});

packages/ai/cypress/support/commands.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,15 @@
3737
// }
3838

3939
import "@ui5/cypress-internal/commands.js";
40-
import "../../../main/cypress/support/commands.js";
40+
import "../../../main/cypress/support/commands.js";
41+
import "./commands/Button.commands.js";
42+
43+
declare global {
44+
namespace Cypress {
45+
interface Chainable {
46+
ui5AIButtonCheckAttributeInTextButton(attrName: string, attrValue: string): Chainable<void>
47+
ui5AIButtonCheckAttributeInArrowButton(attrName: string, attrValue: string): Chainable<void>
48+
ui5AIButtonCheckAttributeSplitButtonRoot(attrName: string, attrValue: string): Chainable<void>
49+
}
50+
}
51+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Button from "../../../src/Button.js";
2+
3+
Cypress.Commands.add("ui5AIButtonCheckAttributeInTextButton", { prevSubject: true }, (subject: JQuery<Button>, attrName: string, attrValue: string) => {
4+
cy.wrap(subject)
5+
.shadow()
6+
.find("[ui5-split-button]")
7+
.shadow()
8+
.find(".ui5-split-text-button")
9+
.should("be.visible")
10+
.should("have.attr", attrName, attrValue);
11+
});
12+
13+
Cypress.Commands.add("ui5AIButtonCheckAttributeInArrowButton", { prevSubject: true }, (subject: JQuery<Button>, attrName: string, attrValue: string) => {
14+
cy.wrap(subject)
15+
.shadow()
16+
.find("[ui5-split-button]")
17+
.shadow()
18+
.find(".ui5-split-arrow-button")
19+
.shadow()
20+
.find(".ui5-button-root")
21+
.should("be.visible")
22+
.should("have.attr", attrName, attrValue);
23+
});
24+
25+
Cypress.Commands.add("ui5AIButtonCheckAttributeSplitButtonRoot", { prevSubject: true }, (subject: JQuery<Button>, attrName: string, attrValue: string) => {
26+
cy.wrap(subject)
27+
.shadow()
28+
.find("[ui5-split-button]")
29+
.shadow()
30+
.find(".ui5-split-button-root")
31+
.should("be.visible")
32+
.should("have.attr", attrName, attrValue);
33+
});

packages/ai/src/Button.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,24 @@ import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
99
import type SplitButton from "@ui5/webcomponents/dist/SplitButton.js";
1010
import type ButtonDesign from "@ui5/webcomponents/dist/types/ButtonDesign.js";
1111
import type ButtonState from "./ButtonState.js";
12+
import { BUTTON_TOOLTIP_TEXT } from "./generated/i18n/i18n-defaults.js";
1213
import "./ButtonState.js";
13-
1414
import ButtonTemplate from "./ButtonTemplate.js";
15+
import {
16+
getEffectiveAriaLabelText,
17+
getAssociatedLabelForTexts,
18+
getAllAccessibleNameRefTexts,
19+
} from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
1520

1621
// Styles
1722
import ButtonCss from "./generated/themes/Button.css.js";
23+
import { i18n } from "@ui5/webcomponents-base/dist/decorators.js";
24+
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
25+
import type { AccessibilityAttributes } from "@ui5/webcomponents-base/dist/types.js";
26+
27+
type AIButtonRootAccessibilityAttributes = Pick<AccessibilityAttributes, "hasPopup" | "roleDescription" | "title">;
28+
type AIButtonArrowButtonAccessibilityAttributes = Pick<AccessibilityAttributes, "hasPopup" | "expanded" | "title">;
29+
type AIButtonAccessibilityAttributes = { root?: AIButtonRootAccessibilityAttributes, arrowButton?: AIButtonArrowButtonAccessibilityAttributes}
1830

1931
/**
2032
* @class
@@ -119,6 +131,31 @@ class Button extends UI5Element {
119131
@property({ type: Boolean, noAttribute: true })
120132
arrowButtonPressed = false;
121133

134+
/**
135+
* Defines the additional accessibility attributes that will be applied to the component.
136+
*
137+
* This property allows for fine-tuned control of ARIA attributes for screen reader support.
138+
* It accepts an object with the following optional fields:
139+
*
140+
* - **root**: Accessibility attributes that will be applied to the root element.
141+
* - **hasPopup**: Indicates the availability and type of interactive popup element (such as a menu or dialog).
142+
* Accepts string values: `"dialog"`, `"grid"`, `"listbox"`, `"menu"`, or `"tree"`.
143+
* - **roleDescription**: Defines a human-readable description for the button's role.
144+
* Accepts any string value.
145+
*
146+
* - **arrowButton**: Accessibility attributes that will be applied to the arrow (split) button element.
147+
* - **hasPopup**: Indicates the type of popup triggered by the arrow button.
148+
* Accepts string values: `"dialog"`, `"grid"`, `"listbox"`, `"menu"`, or `"tree"`.
149+
* - **expanded**: Indicates whether the popup controlled by the arrow button is currently expanded.
150+
* Accepts boolean values: `true` or `false`.
151+
*
152+
* @public
153+
* @since 2.6.0
154+
* @default {}
155+
*/
156+
@property({ type: Object })
157+
accessibilityAttributes: AIButtonAccessibilityAttributes = {};
158+
122159
/**
123160
* Keeps the current state object of the component.
124161
* @private
@@ -150,6 +187,9 @@ class Button extends UI5Element {
150187
@query(".ui5-ai-button-hidden[ui5-split-button]")
151188
_hiddenSplitButton?: SplitButton;
152189

190+
@i18n("@ui5/webcomponents")
191+
static i18nBundle: I18nBundle;
192+
153193
get _hideArrowButton() {
154194
return !this._effectiveStateObject?.showArrowButton;
155195
}
@@ -305,7 +345,30 @@ class Button extends UI5Element {
305345
e.stopImmediatePropagation();
306346
this.fireDecoratorEvent("arrow-button-click");
307347
}
348+
349+
get _computedAccessibilityAttributes(): AIButtonAccessibilityAttributes {
350+
const labelRefTexts = getAllAccessibleNameRefTexts(this) || getEffectiveAriaLabelText(this) || getAssociatedLabelForTexts(this) || "";
351+
352+
const mainTitle = this._hasText ? Button.i18nBundle.getText(BUTTON_TOOLTIP_TEXT, this._stateText as string) : "";
353+
const title = `${mainTitle} ${labelRefTexts}`.trim();
354+
355+
return {
356+
root: {
357+
hasPopup: this.accessibilityAttributes?.root?.hasPopup || "false",
358+
roleDescription: this.accessibilityAttributes?.root?.roleDescription,
359+
title: this.accessibilityAttributes?.root?.title || title,
360+
},
361+
arrowButton: {
362+
hasPopup: this.accessibilityAttributes?.arrowButton?.hasPopup,
363+
expanded: this.accessibilityAttributes?.arrowButton?.expanded,
364+
title: this.accessibilityAttributes?.arrowButton?.title,
365+
},
366+
};
367+
}
308368
}
309369

310370
Button.define();
311371
export default Button;
372+
export type {
373+
AIButtonAccessibilityAttributes,
374+
};

packages/ai/src/ButtonTemplate.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default function ButtonTemplate(this: Button) {
1212
_hideArrowButton={this._hideArrowButton}
1313
onClick={this._onClick}
1414
onArrowClick={this._onArrowClick}
15+
accessibilityAttributes={this._computedAccessibilityAttributes}
1516
>
1617
{this._hasText && (
1718
<div class="ui5-ai-button-text">{this._stateText}</div>

packages/ai/src/i18n/messagebundle.properties

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@
55
PROMPT_INPUT_CHARACTERS_LEFT={0} characters remaining
66

77
#XTXT: Text for characters over
8-
PROMPT_INPUT_CHARACTERS_EXCEEDED={0} characters over limit
8+
PROMPT_INPUT_CHARACTERS_EXCEEDED={0} characters over limit
9+
10+
#XTXT: Text for
11+
BUTTON_TOOLTIP_TEXT={0} with Artificial Intelligence
12+
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11

22
PROMPT_INPUT_CHARACTERS_LEFT={0} characters remaining
33

4-
PROMPT_INPUT_CHARACTERS_EXCEEDED={0} characters over limit
4+
PROMPT_INPUT_CHARACTERS_EXCEEDED={0} characters over limit

packages/ai/test/pages/Button.html

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,17 +145,28 @@
145145
const predefinedTextsSimplified = data.predefinedTextsSimplified;
146146
const predefinedTextsSummarized = data.predefinedTextsSummarized;
147147

148-
function startGeneration(button) {
148+
function startGeneration(button, isSplitButton = false) {
149149
console.warn("startGeneration");
150150
generationId = setTimeout(function() {
151151
console.warn("Generation completed");
152152
button.state = "revise";
153+
button.accessibilityAttributes = {
154+
root: {
155+
hasPopup: "menu",
156+
roleDescription: isSplitButton ? undefined : "Menu Button"
157+
}
158+
};
153159
}, 3000);
154160
}
155161

156-
function stopGeneration() {
162+
function stopGeneration(button) {
157163
console.warn("stopGeneration");
158164
clearTimeout(generationId);
165+
button.accessibilityAttributes = {
166+
root: {
167+
hasPopup: "false"
168+
}
169+
};
159170
}
160171

161172
function aiButtonClickHandler(evt) {
@@ -168,7 +179,7 @@
168179
break;
169180
case "generating":
170181
button.state = "generate";
171-
stopGeneration();
182+
stopGeneration(button);
172183
break;
173184
case "revise":
174185
menu.opener = button;
@@ -185,17 +196,17 @@
185196
case "generate":
186197
prevTriggerState = "generate";
187198
button.state = "generating";
188-
startGeneration(button);
199+
startGeneration(button, true);
189200
break;
190201
case "generating":
191202
button.state = prevTriggerState;
192-
stopGeneration();
203+
stopGeneration(button);
193204
break;
194205
case "revise":
195206
menuReg.open = false;
196207
prevTriggerState = "revise";
197208
button.state = "generating";
198-
startGeneration(button);
209+
startGeneration(button, true);
199210
break;
200211
}
201212
}
@@ -235,7 +246,6 @@
235246

236247
button.arrowButtonPressed = false;
237248
});
238-
239249
myAiButton.addEventListener("click", aiButtonClickHandler);
240250
myAiButtonSplit.addEventListener("click", aiButtonSplitClickHandler);
241251
myAiButtonSplit.addEventListener("arrow-button-click", aiButtonSplitArrowClickHandler);
@@ -276,15 +286,26 @@
276286
generationId = setTimeout(function() {
277287
console.warn("Generation completed");
278288
button.state = "revise";
289+
button.accessibilityAttributes = {
290+
root: {
291+
hasPopup: "menu",
292+
roleDescription: "Menu Button"
293+
}
294+
};
279295
}, 2000);
280296
}
281297

282-
function stopQuickPromptGeneration() {
298+
function stopQuickPromptGeneration(button) {
283299
console.warn("stopGeneration");
284300
clearInterval(generationId);
285301
generationStopped = true;
286302
sendButton.disabled = false;
287303
output.disabled = false;
304+
button.accessibilityAttributes = {
305+
root: {
306+
hasPopup: "false"
307+
}
308+
};
288309
}
289310

290311
sendButton.addEventListener("click", function() {
@@ -420,6 +441,13 @@
420441
if (!generationStopped) {
421442
button.state = "revise";
422443
output.valueState = "None";
444+
button.accessibilityAttributes = {
445+
root: {
446+
hasPopup: "menu",
447+
roleDescription: "Menu Button"
448+
}
449+
};
450+
423451
}
424452
clearInterval(generationId);
425453
sendButton.disabled = false;

packages/base/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type AriaRole = JSX.AriaRole;
2222
export type AriaDisabled = JSX.AriaAttributes["aria-disabled"];
2323
export type AriaChecked = JSX.AriaAttributes["aria-checked"];
2424
export type AriaReadonly = JSX.AriaAttributes["aria-readonly"];
25-
export type AriaHasPopup = "dialog" | "grid" | "listbox" | "menu" | "tree";
25+
export type AriaHasPopup = "dialog" | "grid" | "listbox" | "menu" | "tree" | "false";
2626
export type AriaCurrent = "page" | "step" | "location" | "date" | "time" | "true" | "false" | boolean | undefined;
2727
export type AriaAutoComplete = "list" | "none" | "inline" | "both" | undefined;
2828
export type AriaLandmarkRole = "none" | "banner" | "main" | "region" | "navigation" | "search" | "complementary" | "form" | "contentinfo"
@@ -64,4 +64,6 @@ export type AccessibilityAttributes = {
6464
ariaKeyShortcuts?: string,
6565
ariaCurrent?: AriaCurrent,
6666
current?: AriaCurrent,
67+
roleDescription?: string,
68+
title?: string,
6769
}

0 commit comments

Comments
 (0)