diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index 55a3d929ce60..a10d965fdbc1 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,249 @@ 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; + + // 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) { + 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 f5779d84beb6..ed88b4fb14cd 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -568,6 +568,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 @@ -933,3 +936,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