diff --git a/packages/main/cypress/specs/ListItemCustom.cy.tsx b/packages/main/cypress/specs/ListItemCustom.cy.tsx
new file mode 100644
index 000000000000..690d565ac442
--- /dev/null
+++ b/packages/main/cypress/specs/ListItemCustom.cy.tsx
@@ -0,0 +1,396 @@
+import ListItemCustom from "../../src/ListItemCustom.js";
+import List from "../../src/List.js";
+import Button from "../../src/Button.js";
+import CheckBox from "../../src/CheckBox.js";
+
+describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
+ describe("With pure HTML elements", () => {
+ it("should update invisible text content on focusin and clear on focusout", () => {
+ // Mount ListItemCustom with pure HTML elements
+ cy.mount(
+
+
+
Test Content
+ Additional Text
+
+
+ );
+
+ // Store the component ID for accessing the invisible text span
+ cy.get("#li-custom-html").invoke("prop", "_id").as("itemId");
+
+ // Initially, the invisible text content should be empty
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-html")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "");
+ });
+
+ // Focus the list item
+ cy.get("#li-custom-html").click();
+
+ // After focus, invisible text content should be populated
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-html")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Test Content Additional Text");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-html")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+
+ // Remove focus
+ cy.focused().blur();
+
+ // After blur, invisible text content should be cleared
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-html")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "");
+ });
+ });
+
+ it("should process text content from HTML elements for accessibility", () => {
+ // Mount ListItemCustom with specific text content we can test for
+ cy.mount(
+
+
+
Primary Content
+ Secondary Information
+
Paragraph text
+
+
+ );
+
+ // Store the component ID
+ cy.get("#li-custom-html-content").invoke("prop", "_id").as("itemId");
+
+ // Focus the list item
+ cy.get("#li-custom-html-content").click();
+
+ // Verify text content is processed and included in the invisible text
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-html-content")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Primary Content Secondary Information Paragraph text");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-html-content")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+ });
+ });
+
+ describe("With UI5 components", () => {
+ it("should update invisible text content on focusin and clear on focusout with UI5 components", () => {
+ // Mount ListItemCustom with UI5 components
+ cy.mount(
+
+
+
+
+
+
+ );
+
+ // Store the component ID
+ cy.get("#li-custom-ui5").invoke("prop", "_id").as("itemId");
+
+ // Initially, the invisible text content should be empty
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-ui5")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "");
+ });
+
+ // Focus the list item
+ cy.get("#li-custom-ui5").click();
+
+ // After focus, invisible text content should be populated
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-ui5")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Button Click me Checkbox Check option Not checked Required");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-ui5")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+
+ // Remove focus
+ cy.focused().blur();
+
+ // After blur, invisible text content should be cleared
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-ui5")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "");
+ });
+ });
+
+ it("should handle focus changes between list item and UI5 components", () => {
+ // Mount ListItemCustom with UI5 components
+ cy.mount(
+
+
+
+
+
+
+ );
+
+ // Store the component ID
+ cy.get("#li-custom-ui5-focus").invoke("prop", "_id").as("itemId");
+
+ // Click the list item first to get focus
+ cy.get("#li-custom-ui5-focus").click();
+
+ // Verify invisible text is populated
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-ui5-focus")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Button Click Me Checkbox Check Option Not checked");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-ui5-focus")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+
+ // 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("@itemId").then(itemId => {
+ cy.get("#li-custom-ui5-focus")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Button Click Me Checkbox Check Option Not checked");
+ });
+
+ // Click outside the list to truly remove focus
+ cy.get("body").click({ force: true });
+
+ // Now invisible text should be cleared
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-ui5-focus")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "");
+ });
+ });
+ });
+
+ describe("With mixed elements and nesting", () => {
+ it("should process nested elements for accessibility", () => {
+ // Mount ListItemCustom with nested elements
+ cy.mount(
+
+
+
+ Container Text
+
+
+
+
+
Paragraph outside container
+
+
+ );
+
+ // Store the component ID
+ cy.get("#li-custom-nested").invoke("prop", "_id").as("itemId");
+
+ // Focus the list item
+ cy.get("#li-custom-nested").click();
+
+ // Verify text content is processed and included in the invisible text
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-nested")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Container Text Button Nested Button Paragraph outside container");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-nested")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+ });
+
+ it("should handle deep nesting of elements", () => {
+ // Mount ListItemCustom with deeply nested elements
+ cy.mount(
+
+
+
+
+
+
+
+ Level 2 Text
+
+
+
+
+
+ );
+
+ // Store the component ID
+ cy.get("#li-custom-deep-nested").invoke("prop", "_id").as("itemId");
+
+ // Focus the list item
+ cy.get("#li-custom-deep-nested").click();
+
+ // Verify all nested content is processed
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-deep-nested")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Button Deeply Nested Button Level 2 Text Checkbox Nested Not checked");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-deep-nested")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+
+ // Remove focus
+ cy.focused().blur();
+
+ // After blur, invisible text content should be cleared
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-deep-nested")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .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
+ cy.mount(
+
+
+
Delete Mode Item
+
+
+
+ );
+
+ // Store the component ID
+ cy.get("#li-custom-delete").invoke("prop", "_id").as("itemId");
+
+ // Focus the list item
+ cy.get("#li-custom-delete").click();
+
+ // Verify text content is processed and included in the invisible text
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-delete")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item Delete Mode Item Button Remove");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-delete")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+
+ // Remove focus
+ cy.focused().blur();
+
+ // After blur, invisible text content should be cleared
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-delete")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "");
+ });
+ });
+ });
+
+ describe("Edge cases", () => {
+ it("should handle empty list item content", () => {
+ cy.mount(
+
+
+
+ );
+
+ // Store the component ID
+ cy.get("#li-custom-empty").invoke("prop", "_id").as("itemId");
+
+ // Focus the list item
+ cy.get("#li-custom-empty").click();
+
+ // Should still have basic announcement text
+ cy.get("@itemId").then(itemId => {
+ cy.get("#li-custom-empty")
+ .shadow()
+ .find(`#${itemId}-invisibleTextContent`)
+ .should("have.text", "List Item");
+
+ // Check that aria-labelledby on the internal li element includes the invisibleTextContent span id
+ cy.get("#li-custom-empty")
+ .shadow()
+ .find("li[part='native-li']")
+ .should("have.attr", "aria-labelledby")
+ .and("include", `${itemId}-invisibleTextContent`);
+ });
+ });
+
+ it("should handle list item with accessibleName", () => {
+ cy.mount(
+
+
+
This content should not be announced
+
+
+ );
+
+ // Check that aria-labelledBy on the internal li element doesn't include the ID of the invisibleTextContent span
+ cy.get("#li-custom-accessible-name").invoke("prop", "_id").then(itemId => {
+ cy.get("#li-custom-accessible-name")
+ .shadow()
+ .find("li[part='native-li']")
+ .invoke("attr", "aria-labelledby")
+ .should("not.include", `${itemId}-invisibleTextContent`);
+ });
+ });
+ });
+});
diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts
index 8925892bf181..3e554b390426 100644
--- a/packages/main/src/ListItemCustom.ts
+++ b/packages/main/src/ListItemCustom.ts
@@ -2,11 +2,17 @@ import {
isTabNext, isTabPrevious, isF2, isF7, isUp, isDown,
} from "@ui5/webcomponents-base/dist/Keys.js";
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
+import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import type { ClassMap } from "@ui5/webcomponents-base/dist/types.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
+import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import ListItem from "./ListItem.js";
import ListItemCustomTemplate from "./ListItemCustomTemplate.js";
+import { getCustomAnnouncement } from "./CustomAnnouncement.js";
+import {
+ LISTITEMCUSTOM_TYPE_TEXT,
+} from "./generated/i18n/i18n-defaults.js";
// Styles
import ListItemCustomCss from "./generated/themes/ListItemCustom.css.js";
@@ -36,6 +42,8 @@ import ListItemCustomCss from "./generated/themes/ListItemCustom.css.js";
styles: [ListItem.styles, ListItemCustomCss],
})
class ListItemCustom extends ListItem {
+ @i18n("@ui5/webcomponents")
+ static i18nBundle: I18nBundle;
/**
* Defines whether the item is movable.
* @default false
@@ -80,6 +88,137 @@ class ListItemCustom extends ListItem {
}
}
+ get _accessibleNameRef(): string {
+ if (this.accessibleName) {
+ // accessibleName is set - return labels excluding content
+ return `${this._id}-invisibleText`;
+ }
+
+ // accessibleName is not set - return _accInfo.listItemAriaLabel including custom content announcements
+ return `${this._id}-invisibleTextContent ${this._id}-invisibleText`;
+ }
+
+ _onfocusin(e: FocusEvent) {
+ super._onfocusin(e);
+ // Skip updating invisible text during drag operations
+ if (!this._isDragging()) {
+ this._updateInvisibleTextContent();
+ }
+ }
+
+ _onfocusout(e: FocusEvent) {
+ super._onfocusout(e);
+ // Skip clearing invisible text during drag operations
+ if (!this._isDragging()) {
+ this._clearInvisibleTextContent();
+ }
+ }
+
+ /**
+ * Checks if this element is currently being dragged
+ * @returns {boolean} True if this element is being dragged
+ * @private
+ */
+ _isDragging(): boolean {
+ // Check if this specific element has the data-moving attribute
+ return this.hasAttribute("data-moving");
+ }
+
+ onAfterRendering() {
+ // This will run after the component is rendered
+ if (this.shadowRoot && !this.shadowRoot.querySelector(`#${this._id}-invisibleTextContent`)) {
+ const span = document.createElement("span");
+ span.id = `${this._id}-invisibleTextContent`;
+ span.className = "ui5-hidden-text";
+ // Empty content as requested
+ this.shadowRoot.appendChild(span);
+ }
+ }
+
+ /**
+ * Returns the invisible text span element used for accessibility announcements
+ * @returns {HTMLElement | null} The HTMLElement representing the invisible text span used for accessibility announcements, or null if the element is not found in the shadow DOM
+ * @private
+ */
+ private get _invisibleTextSpan(): HTMLElement | null {
+ return this.shadowRoot?.querySelector(`#${this._id}-invisibleTextContent`) as HTMLElement;
+ }
+
+ private _updateInvisibleTextContent() {
+ const invisibleTextSpan = this._invisibleTextSpan;
+ if (!invisibleTextSpan) {
+ return;
+ }
+
+ // Get accessibility descriptions
+ const accessibilityTexts = this._getAccessibilityDescription();
+
+ // Create a new array with the type text at the beginning
+ const allTexts = [ListItemCustom.i18nBundle.getText(LISTITEMCUSTOM_TYPE_TEXT), ...accessibilityTexts];
+
+ // Update the span content
+ invisibleTextSpan.textContent = allTexts.join(" ");
+ }
+
+ private _clearInvisibleTextContent() {
+ const invisibleTextSpan = this._invisibleTextSpan;
+ if (invisibleTextSpan) {
+ invisibleTextSpan.textContent = "";
+ }
+ }
+
+ /**
+ * Gets accessibility description by processing content nodes and delete buttons
+ * @returns {string[]} Array of accessibility text strings
+ * @private
+ */
+ private _getAccessibilityDescription(): string[] {
+ const accessibilityTexts: string[] = [];
+
+ // Process slotted content elements (default slot)
+ const defaultSlot = this.shadowRoot?.querySelector("slot:not([name])");
+ if (defaultSlot) {
+ const assignedNodes = (defaultSlot as HTMLSlotElement).assignedNodes({ flatten: true });
+ assignedNodes.forEach(child => {
+ const text = getCustomAnnouncement(child, { lessDetails: false }, false);
+ if (text) {
+ accessibilityTexts.push(text);
+ }
+ });
+ }
+
+ // Process delete button in delete mode
+ const deleteButtonNodes = this._getDeleteButtonNodes();
+ deleteButtonNodes.forEach(button => {
+ const text = getCustomAnnouncement(button, { lessDetails: false }, false);
+ if (text) {
+ accessibilityTexts.push(text);
+ }
+ });
+
+ return accessibilityTexts;
+ }
+
+ /**
+ * Gets delete button nodes to process for accessibility
+ * @returns {Node[]} Array of nodes to process
+ * @private
+ */
+ private _getDeleteButtonNodes(): Node[] {
+ if (!this.modeDelete) {
+ return [];
+ }
+
+ if (this.hasDeleteButtonSlot) {
+ // Return custom delete buttons from slot
+ return this.deleteButton;
+ }
+
+ // Return the built-in delete button from the shadow DOM if it exists
+ const deleteButton = this.shadowRoot?.querySelector(`#${this._id}-deleteSelectionElement`);
+ return deleteButton ? [deleteButton] : [];
+ }
+
get classes(): ClassMap {
const result = super.classes;
diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties
index 697f86811a21..1d97c4557d1e 100644
--- a/packages/main/src/i18n/messagebundle.properties
+++ b/packages/main/src/i18n/messagebundle.properties
@@ -619,6 +619,9 @@ TOKEN_ARIA_REMOVE=Remove
#XACT: ARIA announcement for token label
TOKEN_ARIA_LABEL=Token
+#XACT: ARIA announcement for custom list item type
+LISTITEMCUSTOM_TYPE_TEXT=List Item
+
#XACT: ARIA announcement for tokens
TOKENIZER_ARIA_CONTAIN_TOKEN=No Tokens