From c48cfbc8b8333aeabe19980e8618004ef04475a8 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Fri, 14 Aug 2026 15:10:52 +0300 Subject: [PATCH 1/3] fix(ui5-user-settings-dialog): announce selection state to screen readers --- .../cypress/specs/UserSettingsDialog.cy.tsx | 127 ++++++++++++++++++ .../fiori/src/UserSettingsAppearanceView.ts | 13 ++ .../src/UserSettingsAppearanceViewItem.ts | 8 ++ packages/fiori/src/UserSettingsDialog.ts | 8 ++ .../fiori/src/UserSettingsDialogTemplate.tsx | 1 + .../fiori/src/i18n/messagebundle.properties | 5 +- packages/main/src/ListItem.ts | 12 +- 7 files changed, 172 insertions(+), 2 deletions(-) diff --git a/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx b/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx index 8e227ffe9a93a..20067be9d916c 100644 --- a/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx +++ b/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx @@ -1405,6 +1405,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 9c23b41056787..ffa5074f7a4ed 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 8f1cd44ab0f67..22cac936f4490 100644 --- a/packages/fiori/src/UserSettingsAppearanceViewItem.ts +++ b/packages/fiori/src/UserSettingsAppearanceViewItem.ts @@ -68,6 +68,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 3edd22591a817..de6f67dfe274a 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 279a1f92ea71e..9db0a5f0ab997 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 2397251013b95..8e81a248def9b 100644 --- a/packages/fiori/src/i18n/messagebundle.properties +++ b/packages/fiori/src/i18n/messagebundle.properties @@ -618,4 +618,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 668619999151e..5aea5260dfc94 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; } From 065fd2ca79cc3b3d5da496449026428f7c3615ec Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Wed, 23 Sep 2026 11:32:52 +0300 Subject: [PATCH 2/3] refactor(ui5-user-settings-dialog): use native single selection for side navigation Replace the hand-emulated single-selection scheme (list in selectionMode "None" plus a private _forceAriaSelected flag on ListItem and manual InvisibleMessage announcements) with the List's native selectionMode "Single", which provides row highlight, aria-selected, the hidden "Selected/Not Selected" describedby text and a polite announcement. Selection logic now listens to the List selection-change event; a separate item-click handler keeps the narrow-mode drill-in. The public selection-change detail ({ item }) is unchanged. --- .../cypress/specs/UserSettingsDialog.cy.tsx | 47 ++++++++++++- .../fiori/src/UserSettingsAppearanceView.ts | 24 +++---- .../src/UserSettingsAppearanceViewItem.ts | 8 --- .../UserSettingsAppearanceViewTemplate.tsx | 2 +- packages/fiori/src/UserSettingsDialog.ts | 66 ++++++++++++------- .../fiori/src/UserSettingsDialogTemplate.tsx | 3 +- .../fiori/src/i18n/messagebundle.properties | 3 - packages/main/src/ListItem.ts | 12 +--- 8 files changed, 98 insertions(+), 67 deletions(-) diff --git a/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx b/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx index 958c3ec6c2b16..0c4f0f24b190f 100644 --- a/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx +++ b/packages/fiori/cypress/specs/UserSettingsDialog.cy.tsx @@ -1557,7 +1557,7 @@ describe("Selection accessibility", () => { 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)", () => { + it("does not render a radio button in dialog items (selection-mode Single)", () => { cy.mount( @@ -1598,6 +1598,49 @@ describe("Selection accessibility", () => { cy.get("@liveRegion").should("have.text", ""); }); + it("selects and announces on Space/Enter", () => { + 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().as("secondItem"); + + // Move focus to the non-selected item and select it with the keyboard. + cy.get("@secondItem").focus(); + cy.realPress("Enter"); + cy.get("@secondItem").shadow().find("li").should("have.attr", "aria-selected", "true"); + cy.get("@liveRegion").should("contain.text", "Selected"); + }); + + it("arrow keys move focus without changing selection", () => { + cy.mount( + + + + + + + ); + cy.get("[ui5-user-settings-dialog]").as("settings"); + cy.get("@settings").shadow().find("[ui5-dialog]").find("[ui5-li]").first().as("firstItem"); + cy.get("@settings").shadow().find("[ui5-dialog]").find("[ui5-li]").last().as("secondItem"); + + cy.get("@firstItem").focus(); + cy.realPress("ArrowDown"); + + // Focus moved to the second item, but selection stays on the first (Single, not SingleAuto). + cy.get("@secondItem").should("be.focused"); + cy.get("@firstItem").shadow().find("li").should("have.attr", "aria-selected", "true"); + cy.get("@secondItem").shadow().find("li").should("have.attr", "aria-selected", "false"); + }); + it("does not announce when selection-change on the dialog is prevented", () => { cy.mount( @@ -1638,7 +1681,7 @@ describe("Selection accessibility", () => { 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. + // No radio button is rendered - selection mode Single places no selection control. cy.get("@items").first().shadow().find("[ui5-radio-button]").should("not.exist"); // Selecting a different theme announces "Selected". diff --git a/packages/fiori/src/UserSettingsAppearanceView.ts b/packages/fiori/src/UserSettingsAppearanceView.ts index ffa5074f7a4ed..52a29a98178aa 100644 --- a/packages/fiori/src/UserSettingsAppearanceView.ts +++ b/packages/fiori/src/UserSettingsAppearanceView.ts @@ -5,7 +5,7 @@ import type UserSettingsAppearanceViewItem from "./UserSettingsAppearanceViewIte import { isInstanceOfUserSettingsAppearanceViewItem } from "./UserSettingsAppearanceViewItem.js"; import type UserSettingsAppearanceViewGroup from "./UserSettingsAppearanceViewGroup.js"; import { isInstanceOfUserSettingsAppearanceViewGroup } from "./UserSettingsAppearanceViewGroup.js"; -import type { ListItemClickEventDetail } from "@ui5/webcomponents/dist/List.js"; +import type { ListSelectionChangeEventDetail } from "@ui5/webcomponents/dist/List.js"; import type ListItemBase from "@ui5/webcomponents/dist/ListItemBase.js"; import { @@ -13,11 +13,8 @@ import { } 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; @@ -98,23 +95,18 @@ class UserSettingsAppearanceView extends UserSettingsView { return allItems; } - _handleItemClick = (e: CustomEvent) => { - const listItem = e.detail.item as ListItemBase & { associatedSettingItem?: UserSettingsAppearanceViewItem }; + _handleSelectionChange = (e: CustomEvent) => { + const listItem = e.detail.targetItem as ListItemBase & { associatedSettingItem?: UserSettingsAppearanceViewItem }; if (isInstanceOfUserSettingsAppearanceViewItem(listItem)) { - const alreadySelected = listItem.selected; + // The inner list runs in selectionMode="Single", so it already owns the + // item's selected state and provides the accessibility announcement. const eventPrevented = !this.fireDecoratorEvent("selection-change", { item: listItem, }); - if (!eventPrevented) { - this._getAllItems().forEach(viewItem => { - viewItem.selected = false; - }); - listItem.selected = true; - - if (!alreadySelected) { - announce(UserSettingsAppearanceView.i18nBundle.getText(USER_SETTINGS_LIST_ITEM_SELECTED), InvisibleMessageMode.Polite); - } + if (eventPrevented) { + // Revert the list selection so it stays in sync with the model. + e.preventDefault(); } } }; diff --git a/packages/fiori/src/UserSettingsAppearanceViewItem.ts b/packages/fiori/src/UserSettingsAppearanceViewItem.ts index b7dcd3e7aa860..bc7073402a9ff 100644 --- a/packages/fiori/src/UserSettingsAppearanceViewItem.ts +++ b/packages/fiori/src/UserSettingsAppearanceViewItem.ts @@ -69,14 +69,6 @@ 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/UserSettingsAppearanceViewTemplate.tsx b/packages/fiori/src/UserSettingsAppearanceViewTemplate.tsx index 276e910a655fc..ba0f18c4ed008 100644 --- a/packages/fiori/src/UserSettingsAppearanceViewTemplate.tsx +++ b/packages/fiori/src/UserSettingsAppearanceViewTemplate.tsx @@ -7,7 +7,7 @@ export default function UserSettingsAppearanceViewTemplate(this: UserSettingsApp