diff --git a/packages/main/cypress/specs/ListItemCustom.cy.tsx b/packages/main/cypress/specs/ListItemCustom.cy.tsx index 3e6675936076..cbb0a5528a13 100644 --- a/packages/main/cypress/specs/ListItemCustom.cy.tsx +++ b/packages/main/cypress/specs/ListItemCustom.cy.tsx @@ -24,7 +24,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item Test Content Additional Text"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-html") .shadow() @@ -64,7 +64,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item Primary Content Secondary Information Paragraph text"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-html-content") .shadow() @@ -98,7 +98,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item Button Click me Checkbox Check option Not checked Required . Includes elements"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-ui5") .shadow() @@ -131,13 +131,13 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { // Click the list item first to get focus cy.get("#li-custom-ui5-focus").click(); - + // Verify invisible text is populated // 2 tabbable controls, so we expect ". Includes elements" cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item Button Click Me Checkbox Check Option Not checked . Includes elements"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-ui5-focus") .shadow() @@ -152,14 +152,14 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { // Now click the button - this shouldn't trigger focusout on the list item // as it's a child element cy.get("#test-focus-button").click(); - + // Verify invisible text is still populated (list item should maintain focus state) cy.get("#ui5-invisible-text") .should("have.text", "List Item Button Click Me Checkbox Check Option Not checked . Includes elements"); // Click outside the list to truly remove focus cy.get("body").click({ force: true }); - + // Now invisible text should be cleared cy.get("#ui5-invisible-text") .should("have.text", ""); @@ -191,7 +191,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item Container Text Button Nested Button Paragraph outside container . Includes element"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-nested") .shadow() @@ -230,7 +230,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item Button Deeply Nested Button Level 2 Text Checkbox Nested Not checked . Includes elements"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-deep-nested") .shadow() @@ -250,7 +250,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { .should("have.text", ""); }); }); - + describe("With delete mode and custom delete button", () => { it("should handle ListItemCustom with delete mode and custom delete button", () => { // Mount ListItemCustom with delete mode and custom delete button @@ -273,7 +273,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item Delete Mode Item Button Remove . Includes element"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-delete") .shadow() @@ -309,7 +309,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#ui5-invisible-text") .should("exist") .should("have.text", "List Item"); - + // Check that ariaLabelledByElements on the internal li element includes the global invisible text cy.get("#li-custom-empty") .shadow() @@ -321,12 +321,12 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { expect(hasInvisibleText).to.be.true; }); }); - + it("should handle list item with accessibleName", () => { cy.mount( -
This content should not be announced
@@ -351,4 +351,104 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { }); }); }); +}); + +describe("ListItemCustom - aria-labelledby regression tests", () => { + it("should have non-empty aria-labelledby on non-focused items", () => { + cy.mount( + + +
First Item
+
+ +
Second Item
+
+
+ ); + + cy.get("[ui5-list]").as("list"); + + // Neither item is focused — both must have a populated shadow span + cy.get("@list") + .find("[ui5-li-custom]") + .first() + .shadow() + .find("li[part='native-li']") + .then($li => { + const spanId = $li.attr("aria-labelledby")!; + const span = ($li[0].getRootNode() as ShadowRoot).getElementById(spanId); + expect(span?.textContent?.trim()).to.not.be.empty; + }); + + cy.get("@list") + .find("[ui5-li-custom]") + .last() + .shadow() + .find("li[part='native-li']") + .then($li => { + const spanId = $li.attr("aria-labelledby")!; + const span = ($li[0].getRootNode() as ShadowRoot).getElementById(spanId); + expect(span?.textContent?.trim()).to.not.be.empty; + }); + }); + + it("should preserve accessible name when focus moves to a child interactive element", () => { + cy.mount( + + +
Item with button
+ +
+
+ ); + + cy.get("[ui5-list]").as("list"); + + // Focus the list item — this populates the global invisible text + cy.get("@list") + .find("[ui5-li-custom]") + .click(); + + cy.get("#ui5-invisible-text") + .should("have.text", "List Item Item with button Button Action . Includes element"); + + // Move focus to the inner button inside the child ui5-button (C2 scenario) + cy.get("@list") + .find("[ui5-button]") + .shadow() + .find("button") + .focus(); + + // The global invisible text must NOT be cleared — C2 fix ensures + // _clearInvisibleTextContent is not called when focus stays within the item + cy.get("#ui5-invisible-text") + .should("have.text", "List Item Item with button Button Action . Includes element"); + }); + + it("should preserve accessible name after full focus/blur cycle", () => { + cy.mount( + + +
Item Content
+
+
+ ); + + cy.get("[ui5-list]").as("list"); + + // Focus then blur to trigger the full cycle + cy.get("@list") + .find("[ui5-li-custom]") + .click(); + + cy.get("body").click({ force: true }); + + // The shadow span must still contain accessible text after blur — C1 fix + cy.get("@list") + .find("[ui5-li-custom]") + .shadow() + .find("[class='ui5-hidden-text']") + .first() + .should("not.have.text", ""); + }); }); \ No newline at end of file diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index 2cfae94b0c5e..932fc8b786f5 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -12,6 +12,7 @@ import ListItemCustomTemplate from "./ListItemCustomTemplate.js"; import { getCustomAnnouncement, applyCustomAnnouncement } from "./CustomAnnouncement.js"; import { LISTITEMCUSTOM_TYPE_TEXT, + LIST_ITEM_ACTIVE, } from "./generated/i18n/i18n-defaults.js"; // Styles @@ -92,6 +93,25 @@ class ListItemCustom extends ListItem { return `${this._id}-invisibleText`; } + get ariaLabelledByText(): string { + if (this.accessibleName) { + return this.accessibleName; + } + + // Populate shadow span at all times, not only on focus. + const childTexts: string[] = []; + this.childNodes.forEach(child => { + const text = getCustomAnnouncement(child, {}, false); + if (text) { + childTexts.push(text); + } + }); + + const type = ListItemCustom.i18nBundle.getText(LISTITEMCUSTOM_TYPE_TEXT); + const activeText = this.typeActive ? ListItemCustom.i18nBundle.getText(LIST_ITEM_ACTIVE) : undefined; + return [type, ...childTexts, activeText].filter(Boolean).join(" "); + } + _onfocusin(e: FocusEvent) { super._onfocusin(e); // Skip updating invisible text during drag operations @@ -102,8 +122,10 @@ class ListItemCustom extends ListItem { _onfocusout(e: FocusEvent) { super._onfocusout(e); - // Skip clearing invisible text during drag operations - if (!this._isDragging() && !this.accessibleName) { + // Skip clearing invisible text during drag operations or when focus + // moves to a child element within the same list item (e.g. "Show More" link). + const focusMovedToChild = this.contains(e.relatedTarget as Node); + if (!this._isDragging() && !this.accessibleName && !focusMovedToChild) { this._clearInvisibleTextContent(); } } @@ -137,8 +159,13 @@ class ListItemCustom extends ListItem { return; } - // Clear the announcement by passing empty text + // Save the static aria-labelledby before clearing, because applyCustomAnnouncement + // sets ariaLabelledByElements = null which removes the attribute from the DOM entirely. + const ariaLabelledBy = listItem.getAttribute("aria-labelledby"); applyCustomAnnouncement(listItem, ""); + if (ariaLabelledBy && !listItem.getAttribute("aria-labelledby")) { + listItem.setAttribute("aria-labelledby", ariaLabelledBy); + } } /**