diff --git a/packages/main/cypress/specs/ListItemCustom.cy.tsx b/packages/main/cypress/specs/ListItemCustom.cy.tsx
new file mode 100644
index 000000000000..7b6fa6b34bee
--- /dev/null
+++ b/packages/main/cypress/specs/ListItemCustom.cy.tsx
@@ -0,0 +1,406 @@
+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`);
+
+ // Verify that the custom delete button is properly rendered in the shadow DOM
+ cy.get("#li-custom-delete")
+ .shadow()
+ .find("div.ui5-li-deletebtn")
+ .should("exist")
+ .and("contain", "Remove");
+
+ // Check that clicking the delete button triggers the delete event
+ cy.get("#custom-delete-button").should("exist");
+ });
+
+ // 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 55a3d929ce60..2c46b427ccde 100644
--- a/packages/main/src/ListItemCustom.ts
+++ b/packages/main/src/ListItemCustom.ts
@@ -1,10 +1,19 @@
import { isTabNext, isTabPrevious, isF2 } from "@ui5/webcomponents-base/dist/Keys.js";
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
-import type { ClassMap } from "@ui5/webcomponents-base/dist/types.js";
+import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
+import type { ClassMap, AccessibilityInfo } from "@ui5/webcomponents-base/dist/types.js";
+import slot from "@ui5/webcomponents-base/dist/decorators/slot.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 {
+ ACCESSIBILITY_STATE_REQUIRED,
+ ACCESSIBILITY_STATE_DISABLED,
+ ACCESSIBILITY_STATE_READONLY,
+ LISTITEMCUSTOM_TYPE_TEXT,
+} from "./generated/i18n/i18n-defaults.js";
// Styles
import ListItemCustomCss from "./generated/themes/ListItemCustom.css.js";
@@ -34,6 +43,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
@@ -54,6 +65,12 @@ class ListItemCustom extends ListItem {
@property()
declare accessibleName?: string;
+ /**
+ * @public
+ */
+ @slot({ type: Node, "default": true })
+ content!: Array;
+
async _onkeydown(e: KeyboardEvent) {
const isTab = isTabNext(e) || isTabPrevious(e);
const isFocused = this.matches(":focus");
@@ -76,6 +93,247 @@ class ListItemCustom extends ListItem {
super._onkeyup(e);
}
+ 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);
+ this._updateInvisibleTextContent();
+ }
+
+ _onfocusout(e: FocusEvent) {
+ super._onfocusout(e);
+ this._clearInvisibleTextContent();
+ }
+
+ 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 The invisible text span element or null if not found
+ * @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
+ this.content.forEach(child => {
+ this._processNodeForAccessibility(child, accessibilityTexts);
+ });
+
+ // Process delete button in delete mode
+ const deleteButtonNodes = this._getDeleteButtonNodes();
+ deleteButtonNodes.forEach(button => {
+ this._processNodeForAccessibility(button, accessibilityTexts);
+ });
+
+ 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] : [];
+ }
+
+ /**
+ * Processes a node and adds its accessible text to the given array
+ * @param {Node | null} node The node to process
+ * @param {string[]} textArray The array to add the text to
+ * @private
+ */
+ private _processNodeForAccessibility(node: Node | null, textArray: string[]): void {
+ if (!node) {
+ return;
+ }
+
+ const text = this._getElementAccessibleText(node);
+ if (text) {
+ textArray.push(text);
+ }
+ }
+
+ /**
+ * Extract accessible text from a node and its children recursively.
+ * UI5 elements provide accessibilityInfo with description and children.
+ * For elements without accessibilityInfo, we fall back to extracting text content.
+ *
+ * @param {Node | null} node The node to extract text from
+ * @returns {string} The extracted text
+ * @private
+ */
+ private _getElementAccessibleText(nodeArg: Node | null): string {
+ if (!nodeArg) {
+ return "";
+ }
+
+ // Handle text nodes directly
+ if (nodeArg.nodeType === Node.TEXT_NODE) {
+ return nodeArg.textContent?.trim() || "";
+ }
+
+ // Only proceed with Element-specific operations for Element nodes
+ if (nodeArg.nodeType !== Node.ELEMENT_NODE) {
+ return "";
+ }
+
+ const element = nodeArg as Element;
+
+ // First, check for accessibilityInfo - expected for all UI5 elements
+ const accessibilityInfo = (element as any).accessibilityInfo as AccessibilityInfo | undefined;
+ if (accessibilityInfo) {
+ return this._processAccessibilityInfo(accessibilityInfo);
+ }
+
+ // Fallback: If no accessibilityInfo is available, extract text content
+ // This applies to standard HTML elements or UI5 elements missing accessibilityInfo
+
+ // 1. Get direct text nodes
+ const textNodeContent = Array.from(element.childNodes || [])
+ .filter(node => node.nodeType === Node.TEXT_NODE)
+ .map(node => node.textContent?.trim())
+ .filter(Boolean)
+ .join(" ");
+
+ // 2. Process shadow DOM if available (for web components)
+ let shadowContent = "";
+ if ((element as HTMLElement).shadowRoot) {
+ shadowContent = Array.from((element as HTMLElement).shadowRoot!.childNodes)
+ .map(childNode => this._getElementAccessibleText(childNode))
+ .filter(Boolean)
+ .join(" ");
+ }
+
+ // 3. Process child elements recursively
+ const childContent = Array.from(element.children || [])
+ .map(child => this._getElementAccessibleText(child))
+ .filter(Boolean)
+ .join(" ");
+
+ // Combine all text sources
+ return [textNodeContent, shadowContent, childContent].filter(Boolean).join(" ");
+ }
+
+ /**
+ * Process accessibility info from UI5 elements
+ * @param {AccessibilityInfo} accessibilityInfo The accessibility info object
+ * @returns {string} Processed accessibility text
+ * @private
+ */
+ private _processAccessibilityInfo(accessibilityInfo: AccessibilityInfo): string {
+ // Extract primary information from accessibilityInfo
+ const {
+ type, description, required, disabled, readonly, children,
+ } = accessibilityInfo;
+
+ const textParts: string[] = [];
+
+ // Add type and description first
+ if (type) {
+ textParts.push(type);
+ }
+
+ if (description) {
+ textParts.push(description);
+ }
+
+ // Process accessibility children after description if provided
+ let childrenText = "";
+ if (children && children.length > 0) {
+ childrenText = children
+ .map(child => this._getElementAccessibleText(child))
+ .filter(Boolean)
+ .join(" ");
+
+ // Add children text after description
+ if (childrenText) {
+ textParts.push(childrenText);
+ }
+ }
+
+ // Add accessibility states
+ const states: string[] = [];
+ if (required) {
+ states.push(ListItemCustom.i18nBundle.getText(ACCESSIBILITY_STATE_REQUIRED));
+ }
+ if (disabled) {
+ states.push(ListItemCustom.i18nBundle.getText(ACCESSIBILITY_STATE_DISABLED));
+ }
+ if (readonly) {
+ states.push(ListItemCustom.i18nBundle.getText(ACCESSIBILITY_STATE_READONLY));
+ }
+
+ // Build text with states
+ let mainText = textParts.join(" ");
+ if (states.length > 0) {
+ mainText = [mainText, states.join(" ")].filter(Boolean).join(" ");
+ }
+
+ return mainText;
+ }
+
get classes(): ClassMap {
const result = super.classes;
diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties
index 4787fa4fa447..1c295073593a 100644
--- a/packages/main/src/i18n/messagebundle.properties
+++ b/packages/main/src/i18n/messagebundle.properties
@@ -586,6 +586,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
@@ -960,3 +963,12 @@ DYNAMIC_DATE_RANGE_NEXT_COMBINED_TEXT=Next X {0} (included)
#XFLD: Suffix text for included date range options.
DYNAMIC_DATE_RANGE_INCLUDED_TEXT=(included)
+
+#XACT: ARIA announcement for required accessibility state
+ACCESSIBILITY_STATE_REQUIRED=required
+
+#XACT: ARIA announcement for disabled accessibility state
+ACCESSIBILITY_STATE_DISABLED=disabled
+
+#XACT: ARIA announcement for read-only accessibility state
+ACCESSIBILITY_STATE_READONLY=read only