diff --git a/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx b/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx index 9db3e7df18d4..2a90af3460f8 100644 --- a/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx +++ b/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx @@ -1451,6 +1451,133 @@ describe("Appearance view", () => { }); }); +describe("Selection accessibility", () => { + it("exposes selected state via aria-selected and hidden describedby text on dialog items", () => { + cy.mount( + + + + + + + ); + cy.get("[ui5-user-settings-dialog]").as("settings"); + cy.get("@settings").shadow().find("[ui5-dialog]").find("[ui5-li]").as("items"); + + cy.get("@items").first().shadow().find("li").should("have.attr", "aria-selected", "true"); + cy.get("@items").first().shadow().find(".ui5-hidden-text").should("contain.text", "Selected"); + cy.get("@items").last().shadow().find("li").should("have.attr", "aria-selected", "false"); + cy.get("@items").last().shadow().find(".ui5-hidden-text").should("contain.text", "Not Selected"); + }); + + it("does not render a radio button in dialog items (stays selection-mode None)", () => { + cy.mount( + + + + ); + cy.get("[ui5-user-settings-dialog]").shadow().find("[ui5-li]").first() + .shadow().find("[ui5-radio-button]").should("not.exist"); + }); + + it("announces 'Selected' when a different dialog item is selected", () => { + cy.mount( + + + + + + + ); + cy.get("[ui5-user-settings-dialog]").as("settings"); + cy.get(".ui5-invisiblemessage-polite").as("liveRegion").should("have.text", ""); + + cy.get("@settings").shadow().find("[ui5-dialog]").find("[ui5-li]").last().click(); + + cy.get("@liveRegion").should("contain.text", "Selected"); + }); + + it("does not announce when the already-selected dialog item is clicked", () => { + cy.mount( + + + + ); + cy.get("[ui5-user-settings-dialog]").as("settings"); + cy.get(".ui5-invisiblemessage-polite").as("liveRegion").should("have.text", ""); + + cy.get("@settings").shadow().find("[ui5-dialog]").find("[ui5-li]").first().click(); + + cy.get("@liveRegion").should("have.text", ""); + }); + + it("does not announce when selection-change on the dialog is prevented", () => { + cy.mount( + + + + + + + ); + cy.get("[ui5-user-settings-dialog]").as("settings"); + cy.get("@settings").then($settings => { + $settings.get(0).addEventListener("selection-change", (e: Event) => e.preventDefault()); + }); + cy.get(".ui5-invisiblemessage-polite").as("liveRegion").should("have.text", ""); + + cy.get("@settings").shadow().find("[ui5-dialog]").find("[ui5-li]").last().click(); + + cy.get("@liveRegion").should("have.text", ""); + }); + + it("exposes selected state on appearance view items and announces on change", () => { + cy.mount( + + + + + + + + + ); + cy.get("[ui5-user-settings-dialog]").as("settings"); + cy.get("@settings").find("[ui5-user-settings-appearance-view]").as("appearanceView"); + cy.get("@appearanceView").find("[ui5-user-settings-appearance-view-item]").as("items"); + + // Selected theme exposes "Selected"; the grouped one exposes "Not Selected". + cy.get("@items").first().shadow().find("li").should("have.attr", "aria-selected", "true"); + cy.get("@items").first().shadow().find(".ui5-hidden-text").should("contain.text", "Selected"); + cy.get("@items").eq(1).shadow().find(".ui5-hidden-text").should("contain.text", "Not Selected"); + + // No radio button is rendered - selection mode is still None. + cy.get("@items").first().shadow().find("[ui5-radio-button]").should("not.exist"); + + // Selecting a different theme announces "Selected". + cy.get(".ui5-invisiblemessage-polite").as("liveRegion").should("have.text", ""); + cy.get("@items").eq(1).click(); + cy.get("@liveRegion").should("contain.text", "Selected"); + }); + + it("does not announce when the already-selected appearance item is clicked", () => { + cy.mount( + + + + + + ); + cy.get("[ui5-user-settings-dialog]").as("settings"); + cy.get("@settings").find("[ui5-user-settings-appearance-view]").as("appearanceView"); + cy.get(".ui5-invisiblemessage-polite").as("liveRegion").should("have.text", ""); + + cy.get("@appearanceView").find("[ui5-user-settings-appearance-view-item]").first().click(); + + cy.get("@liveRegion").should("have.text", ""); + }); +}); + describe("F6 Navigation", () => { it("tests host has fastnavgroup-container attribute", () => { cy.mount( diff --git a/packages/fiori/src/UserSettingsAppearanceView.ts b/packages/fiori/src/UserSettingsAppearanceView.ts index 9c23b4105678..ffa5074f7a4e 100644 --- a/packages/fiori/src/UserSettingsAppearanceView.ts +++ b/packages/fiori/src/UserSettingsAppearanceView.ts @@ -11,8 +11,13 @@ import type ListItemBase from "@ui5/webcomponents/dist/ListItemBase.js"; import { customElement, slotStrict as slot, eventStrict as event, } from "@ui5/webcomponents-base/dist/decorators.js"; +import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; +import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import type { DefaultSlot, Slot } from "@ui5/webcomponents-base/dist/UI5Element.js"; +import { USER_SETTINGS_LIST_ITEM_SELECTED } from "./generated/i18n/i18n-defaults.js"; type UserSettingsAppearanceViewItemSelectEventDetail = { item: UserSettingsAppearanceViewItem; @@ -49,6 +54,9 @@ type UserSettingsAppearanceViewItemSelectEventDetail = { * @since 2.17.0 */ class UserSettingsAppearanceView extends UserSettingsView { + @i18n("@ui5/webcomponents-fiori") + static i18nBundle: I18nBundle; + eventDetails!: { "selection-change": UserSettingsAppearanceViewItemSelectEventDetail; } @@ -93,6 +101,7 @@ class UserSettingsAppearanceView extends UserSettingsView { _handleItemClick = (e: CustomEvent) => { const listItem = e.detail.item as ListItemBase & { associatedSettingItem?: UserSettingsAppearanceViewItem }; if (isInstanceOfUserSettingsAppearanceViewItem(listItem)) { + const alreadySelected = listItem.selected; const eventPrevented = !this.fireDecoratorEvent("selection-change", { item: listItem, }); @@ -102,6 +111,10 @@ class UserSettingsAppearanceView extends UserSettingsView { viewItem.selected = false; }); listItem.selected = true; + + if (!alreadySelected) { + announce(UserSettingsAppearanceView.i18nBundle.getText(USER_SETTINGS_LIST_ITEM_SELECTED), InvisibleMessageMode.Polite); + } } } }; diff --git a/packages/fiori/src/UserSettingsAppearanceViewItem.ts b/packages/fiori/src/UserSettingsAppearanceViewItem.ts index bc7073402a9f..b7dcd3e7aa86 100644 --- a/packages/fiori/src/UserSettingsAppearanceViewItem.ts +++ b/packages/fiori/src/UserSettingsAppearanceViewItem.ts @@ -69,6 +69,14 @@ class UserSettingsAppearanceViewItem extends ListItemCustom { @property() colorScheme = "Accent7"; + /** + * Exposes the item's `selected` state to assistive technology, since these + * items are always single-select theme options managed by the parent view. + * @private + */ + @property({ type: Boolean }) + _forceAriaSelected = true; + get isUserSettingsAppearanceViewItem(): boolean { return true; } diff --git a/packages/fiori/src/UserSettingsDialog.ts b/packages/fiori/src/UserSettingsDialog.ts index 3edd22591a81..de6f67dfe274 100644 --- a/packages/fiori/src/UserSettingsDialog.ts +++ b/packages/fiori/src/UserSettingsDialog.ts @@ -13,6 +13,8 @@ import type ListItemBase from "@ui5/webcomponents/dist/ListItemBase.js"; import type { PopupBeforeCloseEventDetail } from "@ui5/webcomponents/dist/Popup.js"; import { isPhone, isTablet, isCombi } from "@ui5/webcomponents-base/dist/Device.js"; import MediaRange from "@ui5/webcomponents-base/dist/MediaRange.js"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; +import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js"; import UserSettingsDialogTemplate from "./UserSettingsDialogTemplate.js"; import type UserSettingsItem from "./UserSettingsItem.js"; import UserSettingsDialogCss from "./generated/themes/UserSettingsDialog.css.js"; @@ -25,6 +27,7 @@ import { USER_SETTINGS_DIALOG_SAVE_BUTTON_TEXT, USER_SETTINGS_DIALOG_CANCEL_BUTTON_TEXT, USER_SETTINGS_DIALOG_NO_SEARCH_RESULTS_TEXT, + USER_SETTINGS_LIST_ITEM_SELECTED, } from "./generated/i18n/i18n-defaults.js"; type UserSettingsItemSelectEventDetail = { @@ -286,6 +289,7 @@ class UserSettingsDialog extends UI5Element { _handleItemClick(e: CustomEvent) { const setting = e.detail.item as ListItemBase & { associatedSettingItem: UserSettingsItem }; const settingItem = setting.associatedSettingItem; + const alreadySelected = settingItem.selected; const eventPrevented = !this.fireDecoratorEvent("selection-change", { item: settingItem, }); @@ -299,6 +303,10 @@ class UserSettingsDialog extends UI5Element { item.selected = false; }); settingItem.selected = true; + + if (!alreadySelected) { + announce(UserSettingsDialog.i18nBundle.getText(USER_SETTINGS_LIST_ITEM_SELECTED), InvisibleMessageMode.Polite); + } } } diff --git a/packages/fiori/src/UserSettingsDialogTemplate.tsx b/packages/fiori/src/UserSettingsDialogTemplate.tsx index e3e7a8b291e0..015fa859b495 100644 --- a/packages/fiori/src/UserSettingsDialogTemplate.tsx +++ b/packages/fiori/src/UserSettingsDialogTemplate.tsx @@ -76,6 +76,7 @@ function renderList(this: UserSettingsDialog, items: Array = [ tooltip={item._tooltip} ref={this.captureRef.bind(item)} selected={item.selected} + _forceAriaSelected disabled={item.disabled} accessibleName={item.ariaLabelledByText} type={this._showSettingWithNavigation ? "Navigation" : "Active"} diff --git a/packages/fiori/src/i18n/messagebundle.properties b/packages/fiori/src/i18n/messagebundle.properties index 7a7d505ed443..f97e60564246 100644 --- a/packages/fiori/src/i18n/messagebundle.properties +++ b/packages/fiori/src/i18n/messagebundle.properties @@ -621,4 +621,7 @@ USER_SETTINGS_DIALOG_NO_SEARCH_RESULTS_TEXT=No search results USER_SETTINGS_ACCOUNT_EDIT_AVATAR_TXT=Edit avatar #XTXT: User settings account view manage button -USER_SETTINGS_ACCOUNT_MANAGE_ACCOUNT_BUTTON_TXT=Manage account \ No newline at end of file +USER_SETTINGS_ACCOUNT_MANAGE_ACCOUNT_BUTTON_TXT=Manage account + +#XACT: ARIA announcement when a user settings list item becomes selected +USER_SETTINGS_LIST_ITEM_SELECTED=Selected \ No newline at end of file diff --git a/packages/main/src/ListItem.ts b/packages/main/src/ListItem.ts index 668619999151..5aea5260dfc9 100644 --- a/packages/main/src/ListItem.ts +++ b/packages/main/src/ListItem.ts @@ -194,6 +194,16 @@ abstract class ListItem extends ListItemBase { @property() _forcedAccessibleRole?: string; + /** + * Forces the item to expose its `selected` state to assistive technology + * (hidden "Selected"/"Not Selected" describedby text) even when the parent + * list is not in a selection mode. Used by components that manage selection + * manually, e.g. `ui5-user-settings-dialog`. + * @private + */ + @property({ type: Boolean }) + _forceAriaSelected = false; + @property({ noAttribute: true }) _inheritedAccessibleRole?: string; @@ -450,7 +460,7 @@ abstract class ListItem extends ListItemBase { } get _ariaSelected() { - if (this.modeMultiple || this.modeSingleSelect) { + if (this.modeMultiple || this.modeSingleSelect || this._forceAriaSelected) { return this.selected; }