Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 261 additions & 1 deletion packages/main/src/ListItemCustom.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -54,6 +65,12 @@ class ListItemCustom extends ListItem {
@property()
declare accessibleName?: string;

/**
* @public
*/
@slot({ type: Node, "default": true })
content!: Array<Node>;

async _onkeydown(e: KeyboardEvent) {
const isTab = isTabNext(e) || isTabPrevious(e);
const isFocused = this.matches(":focus");
Expand All @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions packages/main/src/i18n/messagebundle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading