From 57c76307f034aa15863fbb1f9a415c2f69da14be Mon Sep 17 00:00:00 2001 From: Iliana Bobeva Date: Wed, 19 Nov 2025 16:51:13 +0200 Subject: [PATCH 1/6] wip(ui-li-custom): improve accessibility announcements --- packages/main/src/ListItemCustom.ts | 249 ++++++++++++++++++ .../main/src/i18n/messagebundle.properties | 12 + 2 files changed, 261 insertions(+) diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index 55a3d929ce60..51cb06f80440 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -1,10 +1,20 @@ import { isTabNext, isTabPrevious, isF2 } 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 slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; +import type { AccessibilityInfo } from "@ui5/webcomponents-base"; 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 +44,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 +66,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 +94,237 @@ 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 Array of accessibility text strings + */ + 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 Array of nodes to process + */ + private _getDeleteButtonNodes(): Node[] { + if (!this.modeDelete) { + return []; + } + + if (this.hasDeleteButtonSlot) { + // Return custom delete buttons from slot + return this.deleteButton; + } else { + // 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 The node to process + * @param textArray The array to add the text to + */ + 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 The node to extract text from + * @returns The extracted text + */ + private _getElementAccessibleText(node: Node | null): string { + if (!node) { + return ""; + } + + // Handle text nodes directly + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.trim() || ""; + } + + // Only proceed with Element-specific operations for Element nodes + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const element = node 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(node => this._getElementAccessibleText(node)) + .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 The accessibility info object + * @returns Processed accessibility text + * @private + */ + private _processAccessibilityInfo(accessibilityInfo: AccessibilityInfo): string { + // Extract primary information from accessibilityInfo + const { type, description, required, disabled, readonly, children } = accessibilityInfo; + + // Build main text from description (primary) and type + let textParts: string[] = []; + + // Description is the primary content for accessibility + if (description) { + textParts.push(description); + } + + // Type is added next + if (type) { + textParts.push(type); + } + + // 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(" "); + } + + // Process accessibility children if provided + let childrenText = ""; + if (children && children.length > 0) { + childrenText = children + .map(child => this._getElementAccessibleText(child)) + .filter(Boolean) + .join(". "); + + // Combine main text with children text + if (childrenText) { + return [mainText, childrenText].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 4ec85cc590c2..691a1ad56512 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -562,6 +562,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 @@ -927,3 +930,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 From b1d8fdde1e6c6436aab16ccf3e8c334c15db64da Mon Sep 17 00:00:00 2001 From: Iliana Bobeva Date: Wed, 19 Nov 2025 17:08:21 +0200 Subject: [PATCH 2/6] chore: fix lint errors --- packages/main/src/ListItemCustom.ts | 65 ++++++++++++++++------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index 51cb06f80440..555d0625a233 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -1,9 +1,8 @@ import { isTabNext, isTabPrevious, isF2 } 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 type { ClassMap, AccessibilityInfo } from "@ui5/webcomponents-base/dist/types.js"; import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; -import type { AccessibilityInfo } from "@ui5/webcomponents-base"; 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"; @@ -124,7 +123,7 @@ class ListItemCustom extends ListItem { 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 @@ -147,19 +146,20 @@ class ListItemCustom extends ListItem { const allTexts = [ListItemCustom.i18nBundle.getText(LISTITEMCUSTOM_TYPE_TEXT), ...accessibilityTexts]; // Update the span content - invisibleTextSpan.textContent = allTexts.join('. '); + invisibleTextSpan.textContent = allTexts.join(". "); } private _clearInvisibleTextContent() { const invisibleTextSpan = this._invisibleTextSpan; if (invisibleTextSpan) { - invisibleTextSpan.textContent = ''; + invisibleTextSpan.textContent = ""; } } /** * Gets accessibility description by processing content nodes and delete buttons - * @returns Array of accessibility text strings + * @returns {string[]} Array of accessibility text strings + * @private */ private _getAccessibilityDescription(): string[] { const accessibilityTexts: string[] = []; @@ -180,7 +180,8 @@ class ListItemCustom extends ListItem { /** * Gets delete button nodes to process for accessibility - * @returns Array of nodes to process + * @returns {Node[]} Array of nodes to process + * @private */ private _getDeleteButtonNodes(): Node[] { if (!this.modeDelete) { @@ -190,17 +191,18 @@ class ListItemCustom extends ListItem { if (this.hasDeleteButtonSlot) { // Return custom delete buttons from slot return this.deleteButton; - } else { - // Return the built-in delete button from the shadow DOM if it exists - const deleteButton = this.shadowRoot?.querySelector(`#${this._id}-deleteSelectionElement`); - return deleteButton ? [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 The node to process - * @param textArray The array to add the text to + * @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) { @@ -218,25 +220,26 @@ class ListItemCustom extends ListItem { * UI5 elements provide accessibilityInfo with description and children. * For elements without accessibilityInfo, we fall back to extracting text content. * - * @param node The node to extract text from - * @returns The extracted text + * @param {Node | null} node The node to extract text from + * @returns {string} The extracted text + * @private */ - private _getElementAccessibleText(node: Node | null): string { - if (!node) { + private _getElementAccessibleText(nodeArg: Node | null): string { + if (!nodeArg) { return ""; } // Handle text nodes directly - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent?.trim() || ""; + if (nodeArg.nodeType === Node.TEXT_NODE) { + return nodeArg.textContent?.trim() || ""; } // Only proceed with Element-specific operations for Element nodes - if (node.nodeType !== Node.ELEMENT_NODE) { + if (nodeArg.nodeType !== Node.ELEMENT_NODE) { return ""; } - const element = node as Element; + const element = nodeArg as Element; // First, check for accessibilityInfo - expected for all UI5 elements const accessibilityInfo = (element as any).accessibilityInfo as AccessibilityInfo | undefined; @@ -258,7 +261,7 @@ class ListItemCustom extends ListItem { let shadowContent = ""; if ((element as HTMLElement).shadowRoot) { shadowContent = Array.from((element as HTMLElement).shadowRoot!.childNodes) - .map(node => this._getElementAccessibleText(node)) + .map(childNode => this._getElementAccessibleText(childNode)) .filter(Boolean) .join(" "); } @@ -275,8 +278,8 @@ class ListItemCustom extends ListItem { /** * Process accessibility info from UI5 elements - * @param accessibilityInfo The accessibility info object - * @returns Processed accessibility text + * @param {AccessibilityInfo} accessibilityInfo The accessibility info object + * @returns {string} Processed accessibility text * @private */ private _processAccessibilityInfo(accessibilityInfo: AccessibilityInfo): string { @@ -284,7 +287,7 @@ class ListItemCustom extends ListItem { const { type, description, required, disabled, readonly, children } = accessibilityInfo; // Build main text from description (primary) and type - let textParts: string[] = []; + const textParts: string[] = []; // Description is the primary content for accessibility if (description) { @@ -298,9 +301,15 @@ class ListItemCustom extends ListItem { // 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)); + 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(" "); From ae5679fe37fa6e274a93581ac95a12571a873588 Mon Sep 17 00:00:00 2001 From: Iliana Bobeva Date: Wed, 19 Nov 2025 17:17:10 +0200 Subject: [PATCH 3/6] chore: fix lint errors --- packages/main/src/ListItemCustom.ts | 52 +++++++++++++++-------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index 555d0625a233..f4ba102c140b 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -141,10 +141,10 @@ class ListItemCustom extends ListItem { // 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(". "); } @@ -163,7 +163,7 @@ class ListItemCustom extends ListItem { */ private _getAccessibilityDescription(): string[] { const accessibilityTexts: string[] = []; - + // Process slotted content elements this.content.forEach(child => { this._processNodeForAccessibility(child, accessibilityTexts); @@ -174,7 +174,7 @@ class ListItemCustom extends ListItem { deleteButtonNodes.forEach(button => { this._processNodeForAccessibility(button, accessibilityTexts); }); - + return accessibilityTexts; } @@ -187,7 +187,7 @@ class ListItemCustom extends ListItem { if (!this.modeDelete) { return []; } - + if (this.hasDeleteButtonSlot) { // Return custom delete buttons from slot return this.deleteButton; @@ -208,7 +208,7 @@ class ListItemCustom extends ListItem { if (!node) { return; } - + const text = this._getElementAccessibleText(node); if (text) { textArray.push(text); @@ -219,7 +219,7 @@ class ListItemCustom extends ListItem { * 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 @@ -228,35 +228,35 @@ class ListItemCustom extends ListItem { 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) { @@ -265,13 +265,13 @@ class ListItemCustom extends ListItem { .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(" "); } @@ -284,21 +284,23 @@ class ListItemCustom extends ListItem { */ private _processAccessibilityInfo(accessibilityInfo: AccessibilityInfo): string { // Extract primary information from accessibilityInfo - const { type, description, required, disabled, readonly, children } = accessibilityInfo; - + const { + type, description, required, disabled, readonly, children + } = accessibilityInfo; + // Build main text from description (primary) and type const textParts: string[] = []; - + // Description is the primary content for accessibility if (description) { textParts.push(description); } - + // Type is added next if (type) { textParts.push(type); } - + // Add accessibility states const states: string[] = []; if (required) { @@ -310,13 +312,13 @@ class ListItemCustom extends ListItem { 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(" "); } - + // Process accessibility children if provided let childrenText = ""; if (children && children.length > 0) { @@ -324,13 +326,13 @@ class ListItemCustom extends ListItem { .map(child => this._getElementAccessibleText(child)) .filter(Boolean) .join(". "); - + // Combine main text with children text if (childrenText) { return [mainText, childrenText].filter(Boolean).join(". "); } } - + return mainText; } From 64bc36d4f562880e78be11805c86984d9601b8ab Mon Sep 17 00:00:00 2001 From: Iliana Bobeva Date: Wed, 19 Nov 2025 17:20:14 +0200 Subject: [PATCH 4/6] chore: fix lint errors --- packages/main/src/ListItemCustom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index f4ba102c140b..a10d965fdbc1 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -285,7 +285,7 @@ class ListItemCustom extends ListItem { private _processAccessibilityInfo(accessibilityInfo: AccessibilityInfo): string { // Extract primary information from accessibilityInfo const { - type, description, required, disabled, readonly, children + type, description, required, disabled, readonly, children, } = accessibilityInfo; // Build main text from description (primary) and type From c3b050de61344002b4cf4cb373bf407798acf02a Mon Sep 17 00:00:00 2001 From: Iliana Bobeva Date: Tue, 2 Dec 2025 16:05:27 +0200 Subject: [PATCH 5/6] chore: add unit tests --- .../main/cypress/specs/ListItemCustom.cy.tsx | 348 ++++++++++++++++++ packages/main/src/ListItemCustom.ts | 34 +- 2 files changed, 365 insertions(+), 17 deletions(-) create mode 100644 packages/main/cypress/specs/ListItemCustom.cy.tsx diff --git a/packages/main/cypress/specs/ListItemCustom.cy.tsx b/packages/main/cypress/specs/ListItemCustom.cy.tsx new file mode 100644 index 000000000000..d0f6b126cabd --- /dev/null +++ b/packages/main/cypress/specs/ListItemCustom.cy.tsx @@ -0,0 +1,348 @@ +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 Click me Button Check option Checkbox 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 Click Me Button Check Option Checkbox 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 Click Me Button Check Option Checkbox 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 Nested Button 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 Deeply Nested Button Button Level 2 Text Nested Checkbox 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("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 a10d965fdbc1..4fa358182c46 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -146,7 +146,7 @@ class ListItemCustom extends ListItem { const allTexts = [ListItemCustom.i18nBundle.getText(LISTITEMCUSTOM_TYPE_TEXT), ...accessibilityTexts]; // Update the span content - invisibleTextSpan.textContent = allTexts.join(". "); + invisibleTextSpan.textContent = allTexts.join(" "); } private _clearInvisibleTextContent() { @@ -288,7 +288,7 @@ class ListItemCustom extends ListItem { type, description, required, disabled, readonly, children, } = accessibilityInfo; - // Build main text from description (primary) and type + // Build text parts starting with description const textParts: string[] = []; // Description is the primary content for accessibility @@ -296,7 +296,21 @@ class ListItemCustom extends ListItem { textParts.push(description); } - // Type is added next + // 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 but before type + if (childrenText) { + textParts.push(childrenText); + } + } + + // Type is added after children if (type) { textParts.push(type); } @@ -319,20 +333,6 @@ class ListItemCustom extends ListItem { mainText = [mainText, states.join(" ")].filter(Boolean).join(" "); } - // Process accessibility children if provided - let childrenText = ""; - if (children && children.length > 0) { - childrenText = children - .map(child => this._getElementAccessibleText(child)) - .filter(Boolean) - .join(". "); - - // Combine main text with children text - if (childrenText) { - return [mainText, childrenText].filter(Boolean).join(". "); - } - } - return mainText; } From 97a8e18b4617a544dc995f319229a765858fce82 Mon Sep 17 00:00:00 2001 From: Iliana Bobeva Date: Tue, 2 Dec 2025 16:38:17 +0200 Subject: [PATCH 6/6] chore: add test for delete button and move type before description --- .../main/cypress/specs/ListItemCustom.cy.tsx | 68 +++++++++++++++++-- packages/main/src/ListItemCustom.ts | 14 ++-- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/packages/main/cypress/specs/ListItemCustom.cy.tsx b/packages/main/cypress/specs/ListItemCustom.cy.tsx index d0f6b126cabd..7b6fa6b34bee 100644 --- a/packages/main/cypress/specs/ListItemCustom.cy.tsx +++ b/packages/main/cypress/specs/ListItemCustom.cy.tsx @@ -123,7 +123,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#li-custom-ui5") .shadow() .find(`#${itemId}-invisibleTextContent`) - .should("have.text", "List Item Click me Button Check option Checkbox Not checked required"); + .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") @@ -167,7 +167,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#li-custom-ui5-focus") .shadow() .find(`#${itemId}-invisibleTextContent`) - .should("have.text", "List Item Click Me Button Check Option Checkbox Not checked"); + .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") @@ -186,7 +186,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#li-custom-ui5-focus") .shadow() .find(`#${itemId}-invisibleTextContent`) - .should("have.text", "List Item Click Me Button Check Option Checkbox Not checked"); + .should("have.text", "List Item Button Click Me Checkbox Check Option Not checked"); }); // Click outside the list to truly remove focus @@ -230,7 +230,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#li-custom-nested") .shadow() .find(`#${itemId}-invisibleTextContent`) - .should("have.text", "List Item Container Text Nested Button Button Paragraph outside container"); + .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") @@ -270,7 +270,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { cy.get("#li-custom-deep-nested") .shadow() .find(`#${itemId}-invisibleTextContent`) - .should("have.text", "List Item Deeply Nested Button Button Level 2 Text Nested Checkbox Not checked"); + .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") @@ -293,6 +293,64 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => { }); }); + 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( diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index 4fa358182c46..2c46b427ccde 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -288,10 +288,13 @@ class ListItemCustom extends ListItem { type, description, required, disabled, readonly, children, } = accessibilityInfo; - // Build text parts starting with description const textParts: string[] = []; - // Description is the primary content for accessibility + // Add type and description first + if (type) { + textParts.push(type); + } + if (description) { textParts.push(description); } @@ -304,17 +307,12 @@ class ListItemCustom extends ListItem { .filter(Boolean) .join(" "); - // Add children text after description but before type + // Add children text after description if (childrenText) { textParts.push(childrenText); } } - // Type is added after children - if (type) { - textParts.push(type); - } - // Add accessibility states const states: string[] = []; if (required) {