diff --git a/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx
index bed59a7355d1..834c0e557d33 100644
--- a/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx
+++ b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx
@@ -35,6 +35,18 @@ const {
TABLE_ROW_NAVIGABLE: { defaultText: NAVIGABLE },
TABLE_ROW_NAVIGATED: { defaultText: NAVIGATED },
TABLE_ROW_ACTIVE: { defaultText: ACTIVE },
+ TABLE_ROW_ACTION: { defaultText: ACTION_TEMPLATE },
+ TABLE_ROW_ACTIONS_LIST: { defaultText: ACTIONS_LIST_TEMPLATE },
+ TABLE_ROW_MORE_ACTIONS: { defaultText: MORE_ACTIONS },
+ TABLE_ENTERING: { defaultText: ENTERING_TEMPLATE },
+ TABLE_ENTERING_MULTI_SELECTABLE: { defaultText: MULTI_SELECTABLE },
+ TABLE_ENTERING_SELECTED: { defaultText: ENTERING_SELECTED_TEMPLATE },
+ TABLE_ROW_SELECTED_LIVE: { defaultText: SELECTED_LIVE_TEMPLATE },
+ TABLE_ROW_NOT_SELECTED_LIVE: { defaultText: NOT_SELECTED_LIVE_TEMPLATE },
+ TABLE_HIGHLIGHT_NEGATIVE: { defaultText: HIGHLIGHT_NEGATIVE },
+ TABLE_HIGHLIGHT_CRITICAL: { defaultText: HIGHLIGHT_CRITICAL },
+ TABLE_HIGHLIGHT_POSITIVE: { defaultText: HIGHLIGHT_POSITIVE },
+ TABLE_HIGHLIGHT_INFORMATION: { defaultText: HIGHLIGHT_INFORMATION },
} = Translations;
describe("Cell Custom Announcement - More details", () => {
@@ -301,28 +313,23 @@ describe("Row Custom Announcement - Less details", () => {
it("should announce table rows", () => {
cy.get("@row1").realClick();
- checkAnnouncement(`Row . 2 of 2 . ${SELECTED} . ${NAVIGABLE} . H1`);
- checkAnnouncement(`H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button`);
- checkAnnouncement(ONE_ROW_ACTION);
- cy.focused().should("have.attr", "aria-rowindex", "2")
- .should("have.attr", "role", "row");
+ // No identifier column → legacy format: Row → position → selected → navigable/active → cells → actions → navigated
+ checkAnnouncement(`Row . 2 of 2 . ${SELECTED} . ${NAVIGABLE} . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button . ${ONE_ROW_ACTION} . ${NAVIGATED}`);
cy.get("#selection").invoke("attr", "selected", "");
- checkAnnouncement(`Row . 2 of 2 . ${NAVIGABLE}`, true);
+ checkAnnouncement(`Row . 2 of 2 . ${NAVIGABLE} . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button . ${ONE_ROW_ACTION} . ${NAVIGATED}`, true);
cy.get("#row1-nav-action").invoke("prop", "interactive", true);
- checkAnnouncement(`Row . 2 of 2 . ${ACTIVE} . H1`, true);
- checkAnnouncement(Table.i18nBundle.getText(MULTIPLE_ACTIONS, 2));
+ checkAnnouncement(`Row . 2 of 2 . ${ACTIVE} . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button . ${MULTIPLE_ACTIONS.replace("{0}", 2)} . ${NAVIGATED}`, true);
cy.get("@row1").invoke("prop", "interactive", false);
- checkAnnouncement(`Row . 2 of 2 . H1`, true);
+ checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button . ${MULTIPLE_ACTIONS.replace("{0}", 2)} . ${NAVIGATED}`, true);
cy.get("#table0").invoke("css", "width", "301px");
- checkAnnouncement(`Row . 2 of 2 . H1`, true);
- checkAnnouncement(`H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4Popin . C4 Button C4Button`);
+ checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4Popin . C4 Button C4Button . ${MULTIPLE_ACTIONS.replace("{0}", 2)} . ${NAVIGATED}`, true);
cy.get("#Header3").invoke("prop", "popinHidden", true);
- checkAnnouncement(`H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H4Popin . C4 Button C4Button`, true);
+ checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H4Popin . C4 Button C4Button . ${MULTIPLE_ACTIONS.replace("{0}", 2)} . ${NAVIGATED}`, true);
cy.get("#row1-nav-action").invoke("remove");
cy.get("#row1-add-action").invoke("remove");
@@ -423,3 +430,219 @@ describe("Row Custom Announcement - Less details", () => {
});
});
});
+
+describe("Identifier Column Announcement", () => {
+ function checkAnnouncement(expectedText: string, check = "equal") {
+ cy.get("body").then($body => {
+ expect($body.find("#ui5-invisible-text").text())[check](expectedText);
+ });
+ }
+
+ it("should announce only identifier column when set", () => {
+ cy.mount(
+
+
+
+ Company
+ City
+
+
+ 305382373
+ SAP SE
+ Walldorf
+
+
+ 123456789
+ Acme Corp
+ Berlin
+
+
+ );
+
+ // First focus on row1 — entering text + identifier + Row + position
+ cy.get("[ui5-table-row]").first().realClick();
+ checkAnnouncement("with 2 rows", "contains");
+ checkAnnouncement("Document Number 305382373 . Row . 2 of 3", "contains");
+
+ // Focus row2 — no entering announcement
+ cy.realPress("ArrowDown");
+ checkAnnouncement("Document Number 123456789 . Row . 3 of 3");
+
+ // Remove identifier — fallback to legacy format (Row first)
+ cy.get("#docNumHeader").invoke("prop", "identifier", false);
+ cy.realPress("ArrowUp");
+ cy.realPress("ArrowDown");
+ checkAnnouncement("Row . 3 of 3 . Document Number . 123456789 . Company . Acme Corp . City . Berlin");
+ });
+
+ it("should set role=rowheader on identifier column cells", () => {
+ cy.mount(
+
+
+ ID
+ Name
+
+
+ 001
+ Alice
+
+
+ );
+
+ cy.get("#idCell").should("have.attr", "role", "rowheader");
+ cy.get("#nameCell").should("have.attr", "role", "gridcell");
+ });
+});
+
+describe("Row Highlight Announcement", () => {
+ function checkAnnouncement(expectedText: string, focusAgain = false, check = "equal") {
+ if (focusAgain) {
+ cy.realPress("ArrowUp");
+ cy.realPress("ArrowDown");
+ }
+
+ cy.get("body").then($body => {
+ expect($body.find("#ui5-invisible-text").text())[check](expectedText);
+ });
+ }
+
+ it("should announce highlight state and text", () => {
+ cy.mount(
+
+
+ Order
+ Status
+
+
+ 12345
+ Cancelled
+
+
+ 67890
+ Pending
+
+
+ 11111
+ Complete
+
+
+ 22222
+ New
+
+
+ );
+
+ // Row 1: Negative + "Cancelled" (first focus includes entering text)
+ cy.get("#row1").realClick();
+ checkAnnouncement(`Order 12345 . Row . ${HIGHLIGHT_NEGATIVE} . Cancelled . 2 of 5`, false, "contains");
+
+ // Row 2: Critical + "Return Initiated"
+ cy.realPress("ArrowDown");
+ checkAnnouncement(`Order 67890 . Row . ${HIGHLIGHT_CRITICAL} . Return Initiated . 3 of 5`);
+
+ // Row 3: Positive, no text
+ cy.realPress("ArrowDown");
+ checkAnnouncement(`Order 11111 . Row . ${HIGHLIGHT_POSITIVE} . 4 of 5`);
+
+ // Row 4: Information + "Unread"
+ cy.realPress("ArrowDown");
+ checkAnnouncement(`Order 22222 . Row . ${HIGHLIGHT_INFORMATION} . Unread . 5 of 5`);
+
+ // Change highlight to None — no highlight announcement
+ cy.get("#row4").invoke("prop", "highlight", "None");
+ checkAnnouncement("Order 22222 . Row . 5 of 5", true);
+ });
+});
+
+describe("Entering-the-Table Announcement", () => {
+ function checkAnnouncement(expectedText: string, check = "contains") {
+ cy.get("body").then($body => {
+ expect($body.find("#ui5-invisible-text").text())[check](expectedText);
+ });
+ }
+
+ it("should announce table info on first focus only", () => {
+ cy.mount(
+
+
+
+ Name
+
+
+ Alice
+
+
+ Bob
+
+
+ );
+
+ // Add an external button to allow focusing outside the table
+ cy.document().then(doc => {
+ const btn = doc.createElement("button");
+ btn.id = "outside-btn";
+ btn.textContent = "Outside";
+ doc.body.appendChild(btn);
+ });
+
+ // First focus — entering announcement is part of the custom announcement
+ cy.get("[ui5-table-row]").first().realClick();
+ cy.focused().should("have.attr", "ui5-table-row");
+ checkAnnouncement(`with 2 rows`);
+ checkAnnouncement(`${MULTI_SELECTABLE}`);
+ checkAnnouncement(`1 rows selected`);
+ checkAnnouncement(`Name Alice . Row . 2 of 3`);
+
+ // Navigate to next row — no entering announcement, just row content
+ cy.realPress("ArrowDown");
+ checkAnnouncement("Name Bob . Row . 3 of 3", "equal");
+
+ // Focus outside the table and back — entering announcement appears again
+ cy.get("#outside-btn").realClick();
+ cy.get("[ui5-table-row]").first().realClick();
+ cy.focused().should("have.attr", "ui5-table-row");
+ checkAnnouncement(`with 2 rows`);
+ checkAnnouncement(`${MULTI_SELECTABLE}`);
+ });
+});
+
+describe("Row Actions Announcement Format", () => {
+ function checkAnnouncement(expectedText: string, focusAgain = false, check = "contains") {
+ if (focusAgain) {
+ cy.realPress("ArrowUp");
+ cy.realPress("ArrowDown");
+ }
+
+ cy.get("body").then($body => {
+ expect($body.find("#ui5-invisible-text").text())[check](expectedText);
+ });
+ }
+
+ it("should announce action text in row announcement", () => {
+ cy.mount(
+
+
+ Name
+
+
+ Alice
+
+
+
+
+
+ );
+
+ // Multiple actions
+ cy.get("#row1").realClick();
+ checkAnnouncement(`${ACTIONS_LIST_TEMPLATE.replace("{0}", "Add, Edit, Navigation")}`);
+
+ // Remove an action — format changes
+ cy.get("#row1").find("[ui5-table-row-action]").last().invoke("remove");
+ checkAnnouncement(`${ACTIONS_LIST_TEMPLATE.replace("{0}", "Add, Navigation")}`, true);
+
+ // Remove another — single action
+ cy.get("#row1").find("[ui5-table-row-action]").first().invoke("remove");
+ checkAnnouncement(`${ACTION_TEMPLATE.replace("{0}", "Navigation")}`, true);
+ });
+});
diff --git a/packages/main/src/Table.ts b/packages/main/src/Table.ts
index 6e864a648f71..0400b488cee8 100644
--- a/packages/main/src/Table.ts
+++ b/packages/main/src/Table.ts
@@ -401,6 +401,9 @@ class Table extends UI5Element {
@property({ type: Boolean, noAttribute: true })
_renderNavigated = false;
+ @property({ type: Boolean, noAttribute: true })
+ _renderHighlight = false;
+
@query("[ui5-drop-indicator]")
dropIndicatorDOM!: DropIndicator;
@@ -456,8 +459,10 @@ class Table extends UI5Element {
onBeforeRendering(): void {
this._renderNavigated = this.rows.some(row => row.navigated);
+ this._renderHighlight = this.rows.some(row => row.highlight !== "None");
[...this.headerRow, ...this.rows].forEach((row, index) => {
row._renderNavigated = this._renderNavigated;
+ row._hasHighlight = this._renderHighlight;
row._rowActionCount = this.rowActionCount;
row._alternate = this.alternateRowColors && index % 2 === 0;
});
@@ -634,6 +639,11 @@ class Table extends UI5Element {
const widths = [];
const visibleHeaderCells = this.headerRow[0]._visibleCells;
+ // Highlight Cell Width
+ if (this._renderHighlight) {
+ widths.push("var(--_ui5_table_highlight_width)");
+ }
+
// Selection Cell Width
if (this._isRowSelectorRequired) {
widths.push("min-content");
diff --git a/packages/main/src/TableCell.ts b/packages/main/src/TableCell.ts
index ddfb0ffedca3..a071cec2f9f8 100644
--- a/packages/main/src/TableCell.ts
+++ b/packages/main/src/TableCell.ts
@@ -60,6 +60,13 @@ class TableCell extends TableCellBase {
this.style.textAlign = `var(--halign-${this._headerCell._id})`;
this.style.justifyContent = `var(--halign-${this._headerCell._id})`;
}
+
+ if (this._headerCell) {
+ const newRole = this._headerCell.identifier ? "rowheader" : this.ariaRole;
+ if (this.getAttribute("role") !== newRole) {
+ this.setAttribute("role", newRole);
+ }
+ }
}
_injectHeaderNodes(ref: HTMLElement | null) {
diff --git a/packages/main/src/TableCustomAnnouncement.ts b/packages/main/src/TableCustomAnnouncement.ts
index 09917a9449fd..80283442b18f 100644
--- a/packages/main/src/TableCustomAnnouncement.ts
+++ b/packages/main/src/TableCustomAnnouncement.ts
@@ -1,5 +1,7 @@
import TableExtension from "./TableExtension.js";
import { getCustomAnnouncement, applyCustomAnnouncement } from "./CustomAnnouncement.js";
+import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js";
+import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js";
import type Table from "./Table.js";
import type TableRow from "./TableRow.js";
import type TableCell from "./TableCell.js";
@@ -12,6 +14,11 @@ import {
TABLE_ROW_NAVIGABLE,
TABLE_ROW_NAVIGATED,
TABLE_COLUMN_HEADER_ROW,
+ TABLE_ENTERING,
+ TABLE_ENTERING_MULTI_SELECTABLE,
+ TABLE_ENTERING_SELECTED,
+ TABLE_ROW_SELECTED_LIVE,
+ TABLE_ROW_NOT_SELECTED_LIVE,
} from "./generated/i18n/i18n-defaults.js";
/**
@@ -23,6 +30,8 @@ import {
class TableCustomAnnouncement extends TableExtension {
_table: Table;
_tableAttributes = ["ui5-table-header-row", "ui5-table-header-cell", "ui5-table-row", "ui5-table-cell"];
+ _hasBeenFocused = false;
+ _focusLeaveTimer?: ReturnType;
constructor(table: Table) {
super();
@@ -39,6 +48,12 @@ class TableCustomAnnouncement extends TableExtension {
return;
}
+ // Cancel any pending focus-leave timer, since focus is still in the table
+ if (this._focusLeaveTimer) {
+ clearTimeout(this._focusLeaveTimer);
+ this._focusLeaveTimer = undefined;
+ }
+
const tableElementName = tableAttribute.replace("ui5-table", "Table").replace(/-([a-z])/g, g => g[1].toUpperCase());
const eventHandlerName = `_handle${tableElementName}Focusin` as keyof TableCustomAnnouncement;
const eventHandler = this[eventHandlerName] as (target: HTMLElement, e?: FocusEvent) => void;
@@ -49,9 +64,17 @@ class TableCustomAnnouncement extends TableExtension {
}
}
- _onfocusout(e: FocusEvent, eventOrigin: HTMLElement) {
+ _onfocusout(_e: FocusEvent, eventOrigin: HTMLElement) {
const isTableElement = this._tableAttributes.some(attr => eventOrigin.hasAttribute(attr));
isTableElement && applyCustomAnnouncement(eventOrigin);
+
+ // Schedule a focus-leave check. If no focusin follows within the same
+ // event cycle (i.e., focus truly left the table), reset the entering flag.
+ // Internal navigation (row-to-row) will cancel this via _onfocusin.
+ this._focusLeaveTimer = setTimeout(() => {
+ this._focusLeaveTimer = undefined;
+ this._hasBeenFocused = false;
+ }, 0);
}
_handleTableElementFocusin(element: HTMLElement) {
@@ -85,6 +108,66 @@ class TableCustomAnnouncement extends TableExtension {
return;
}
+ const identifierCell = row._identifierCell;
+ const identifierHeaderCell = row._identifierHeaderCell;
+
+ if (identifierHeaderCell && identifierCell) {
+ this._handleTableRowFocusinNew(row, identifierHeaderCell, identifierCell);
+ } else {
+ this._handleTableRowFocusinLegacy(row);
+ }
+ }
+
+ /**
+ * New announcement format when identifier column is set:
+ * [entering] → identifier value → "Row" → highlight → highlightText → actions → position
+ */
+ _handleTableRowFocusinNew(row: TableRow, identifierHeaderCell: HTMLElement, identifierCell: TableCell) {
+ const descriptions: string[] = [];
+
+ // Entering-the-table one-time announcement
+ if (!this._hasBeenFocused) {
+ this._hasBeenFocused = true;
+ const enteringDescription = this._getEnteringDescription();
+ if (enteringDescription) {
+ descriptions.push(enteringDescription);
+ }
+ }
+
+ // Identifier column value (header text + cell value)
+ const headerText = getCustomAnnouncement(identifierHeaderCell, { lessDetails: true });
+ const cellText = getCustomAnnouncement(identifierCell, { lessDetails: true });
+ descriptions.push(`${headerText} ${cellText}`.trim());
+
+ // Role: "Row"
+ descriptions.push(this.i18nBundle.getText(TABLE_ROW));
+
+ // Highlight state
+ const highlightDescription = row._highlightDescription;
+ if (highlightDescription) {
+ descriptions.push(highlightDescription);
+ if (row.highlightText) {
+ descriptions.push(row.highlightText);
+ }
+ }
+
+ // Actions
+ const actionDescription = row._actionDescriptionText;
+ if (actionDescription) {
+ descriptions.push(actionDescription);
+ }
+
+ // Position: "X of Y"
+ descriptions.push(this.i18nBundle.getText(TABLE_ROW_INDEX, row.ariaRowIndex as string, this._table._ariaRowCount));
+
+ applyCustomAnnouncement(row, descriptions);
+ }
+
+ /**
+ * Original announcement format when no identifier column is set:
+ * "Row" → position → selected → navigable/active → ALL cells → actions count → navigated
+ */
+ _handleTableRowFocusinLegacy(row: TableRow) {
const descriptions = [
this.i18nBundle.getText(TABLE_ROW),
this.i18nBundle.getText(TABLE_ROW_INDEX, row.ariaRowIndex!, this._table._ariaRowCount),
@@ -132,6 +215,37 @@ class TableCustomAnnouncement extends TableExtension {
this._handleTableElementFocusin(cell);
}
}
+
+ _getEnteringDescription(): string | undefined {
+ const tableLabel = this._table._ariaLabel || this._table._ariaDescription || "";
+ const rowCount = this._table.rows.length;
+ if (!tableLabel && rowCount === 0) {
+ return undefined;
+ }
+
+ const parts = [this.i18nBundle.getText(TABLE_ENTERING, tableLabel, rowCount)];
+
+ const selection = this._table._getSelection();
+ if (selection?.hasAttribute("ui5-table-selection-multi")) {
+ parts.push(this.i18nBundle.getText(TABLE_ENTERING_MULTI_SELECTABLE));
+ const selectedCount = this._table.rows.filter(r => r._isSelected).length;
+ if (selectedCount > 0) {
+ parts.push(this.i18nBundle.getText(TABLE_ENTERING_SELECTED, selectedCount));
+ }
+ }
+
+ return parts.join(" , ");
+ }
+
+ /**
+ * Announces the selection state change via InvisibleMessage.
+ * Called by selection features after selection changes.
+ */
+ announceSelectionChange(selectedCount: number, isSelected: boolean) {
+ const bundleKey = isSelected ? TABLE_ROW_SELECTED_LIVE : TABLE_ROW_NOT_SELECTED_LIVE;
+ const text = this.i18nBundle.getText(bundleKey, selectedCount);
+ announce(text, InvisibleMessageMode.Assertive);
+ }
}
export default TableCustomAnnouncement;
diff --git a/packages/main/src/TableHeaderCell.ts b/packages/main/src/TableHeaderCell.ts
index 5107c46ffadf..d755fa438d58 100644
--- a/packages/main/src/TableHeaderCell.ts
+++ b/packages/main/src/TableHeaderCell.ts
@@ -112,6 +112,24 @@ class TableHeaderCell extends TableCellBase {
@property({ type: Boolean })
popinHidden: boolean = false;
+ /**
+ * Defines whether the column is the identifier column of the table.
+ *
+ * The identifier column is the primary column that identifies each row.
+ * When a row receives focus, the screen reader will announce the identifier cell value
+ * instead of all cell values, providing a concise row description.
+ *
+ * Cells in the identifier column will have the `rowheader` role for better accessibility.
+ *
+ * **Note:** Only one column should be marked as identifier per table.
+ *
+ * @default false
+ * @since 2.23.0
+ * @public
+ */
+ @property({ type: Boolean })
+ identifier = false;
+
/**
* Defines the action of the column.
*
diff --git a/packages/main/src/TableHeaderRowTemplate.tsx b/packages/main/src/TableHeaderRowTemplate.tsx
index 6a598a1eaadc..68a68f267124 100644
--- a/packages/main/src/TableHeaderRowTemplate.tsx
+++ b/packages/main/src/TableHeaderRowTemplate.tsx
@@ -9,6 +9,10 @@ import type TableHeaderRow from "./TableHeaderRow.js";
export default function TableHeaderRowTemplate(this: TableHeaderRow, ariaColIndex: number = 1) {
return (
<>
+ { this._hasHighlight &&
+
+ }
+
{ this._hasSelector &&
{
@property({ type: Boolean })
navigated = false;
+ /**
+ * Defines the highlight state of the row.
+ *
+ * @default "None"
+ * @since 2.23.0
+ * @public
+ */
+ @property()
+ highlight: `${Highlight}` = "None";
+
+ /**
+ * Defines the text associated with the highlight state of the row.
+ *
+ * This text is announced by the screen reader together with the highlight state.
+ *
+ * @default undefined
+ * @since 2.23.0
+ * @public
+ */
+ @property()
+ highlightText?: string;
+
/**
* Defines whether the row is movable.
*
@@ -198,6 +224,71 @@ class TableRow extends TableRowBase {
}) !== undefined;
}
+ get _identifierCell(): TableCell | undefined {
+ if (!this._table) {
+ return undefined;
+ }
+ const headerRow = this._table.headerRow[0];
+ if (!headerRow) {
+ return undefined;
+ }
+ const identifierIndex = headerRow.cells.findIndex(cell => cell.identifier);
+ if (identifierIndex === -1) {
+ return undefined;
+ }
+ return this.cells[identifierIndex];
+ }
+
+ get _identifierHeaderCell(): TableHeaderCell | undefined {
+ if (!this._table) {
+ return undefined;
+ }
+ const headerRow = this._table.headerRow[0];
+ return headerRow?.cells.find(cell => cell.identifier);
+ }
+
+ get _actionDescriptionText(): string | undefined {
+ const actionTexts: string[] = [];
+ const fixedActions = this._fixedActions.filter(a => !a.invisible && a._isInteractive);
+ const flexibleActions = this._flexibleActions.filter(a => !a.invisible && a._isInteractive);
+
+ // Collect texts from visible interactive actions
+ [...flexibleActions, ...fixedActions].forEach(action => {
+ actionTexts.push(action._text);
+ });
+
+ // Add "more actions" if overflow exists
+ if (this._hasOverflowActions) {
+ actionTexts.push(TableRowBase.i18nBundle.getText(TABLE_ROW_MORE_ACTIONS));
+ }
+
+ if (actionTexts.length === 0) {
+ return undefined;
+ }
+
+ if (actionTexts.length === 1) {
+ return TableRowBase.i18nBundle.getText(TABLE_ROW_ACTION, actionTexts[0]);
+ }
+
+ return TableRowBase.i18nBundle.getText(TABLE_ROW_ACTIONS_LIST, actionTexts.join(", "));
+ }
+
+ get _highlightDescription(): string | undefined {
+ if (this.highlight === "None") {
+ return undefined;
+ }
+
+ const highlightI18nMap: Record = {
+ Negative: TABLE_HIGHLIGHT_NEGATIVE,
+ Critical: TABLE_HIGHLIGHT_CRITICAL,
+ Positive: TABLE_HIGHLIGHT_POSITIVE,
+ Information: TABLE_HIGHLIGHT_INFORMATION,
+ };
+
+ const i18nKey = highlightI18nMap[this.highlight];
+ return i18nKey ? TableRowBase.i18nBundle.getText(i18nKey) : undefined;
+ }
+
get _hasPopin() {
return this.cells.some(c => c._popin && !c._popinHidden);
}
diff --git a/packages/main/src/TableRowBase.ts b/packages/main/src/TableRowBase.ts
index c9fdf238d181..7a0f516fe6b4 100644
--- a/packages/main/src/TableRowBase.ts
+++ b/packages/main/src/TableRowBase.ts
@@ -37,6 +37,9 @@ abstract class TableRowBase extends
@property({ type: Boolean, noAttribute: true })
_renderNavigated = false;
+ @property({ type: Boolean })
+ _hasHighlight = false;
+
@property({ type: Boolean, noAttribute: true })
_alternate = false;
diff --git a/packages/main/src/TableRowTemplate.tsx b/packages/main/src/TableRowTemplate.tsx
index d882efe05185..7c3e2e911e37 100644
--- a/packages/main/src/TableRowTemplate.tsx
+++ b/packages/main/src/TableRowTemplate.tsx
@@ -9,6 +9,16 @@ import type TableRow from "./TableRow.js";
export default function TableRowTemplate(this: TableRow, ariaColIndex: number = 1) {
return (
<>
+ { this._hasHighlight &&
+
+
+
+ }
+
{ this._hasSelector &&
row._invalidate++);
}
}
+
+ /**
+ * Announces the selection state change via the table's custom announcement extension.
+ * @param isSelected Whether the row was selected or deselected
+ */
+ protected _announceSelectionChange(isSelected: boolean) {
+ if (!this._table) {
+ return;
+ }
+ const selectedCount = this._table.rows.filter(r => r._isSelected).length;
+ const customAnnouncement = this._table._tableCustomAnnouncement;
+ customAnnouncement?.announceSelectionChange(selectedCount, isSelected);
+ }
}
export default TableSelectionBase;
diff --git a/packages/main/src/TableSelectionMulti.ts b/packages/main/src/TableSelectionMulti.ts
index 36ad46d319e8..c0cce8ae2e83 100644
--- a/packages/main/src/TableSelectionMulti.ts
+++ b/packages/main/src/TableSelectionMulti.ts
@@ -123,7 +123,10 @@ class TableSelectionMulti extends TableSelectionBase {
if (selectionChanged) {
this.setSelectedAsSet(selectedSet);
- fireEvent && this.fireDecoratorEvent("change");
+ if (fireEvent) {
+ this.fireDecoratorEvent("change");
+ this._announceSelectionChange(selected);
+ }
}
}
@@ -332,6 +335,7 @@ class TableSelectionMulti extends TableSelectionBase {
}
selectionChanged && this.fireDecoratorEvent("change");
+ selectionChanged && this._announceSelectionChange(this._rangeSelection.selected);
}
_stopRangeSelection() {
diff --git a/packages/main/src/TableSelectionSingle.ts b/packages/main/src/TableSelectionSingle.ts
index 85332ec173ec..e4872987965b 100644
--- a/packages/main/src/TableSelectionSingle.ts
+++ b/packages/main/src/TableSelectionSingle.ts
@@ -52,7 +52,10 @@ class TableSelectionSingle extends TableSelectionBase {
const rowKey = this.getRowKey(row);
if (rowKey) {
this.selected = selected ? rowKey : undefined;
- fireEvent && this.fireDecoratorEvent("change");
+ if (fireEvent) {
+ this.fireDecoratorEvent("change");
+ this._announceSelectionChange(selected);
+ }
}
}
diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties
index 84425a6a3b5e..6b432193b7a0 100644
--- a/packages/main/src/i18n/messagebundle.properties
+++ b/packages/main/src/i18n/messagebundle.properties
@@ -901,9 +901,35 @@ TABLE_ROW_SINGLE_ACTION=1 row action available
#XACT: Screenreader announcement when several actions are available
TABLE_ROW_MULTIPLE_ACTIONS={0} row actions available
#XACT: ARIA description for the row action navigation
-TABLE_NAVIGATION=Navigation
+TABLE_NAVIGATION=Navigate to Details
#XTOL: Tooltip for the AI button in the column header to indicate that the column is generated by AI
TABLE_GENERATED_BY_AI=Generated by AI
+#XACT: Screenreader announcement when a single row action is available, {0} is the action text
+TABLE_ROW_ACTION=Action: {0}
+#XACT: Screenreader announcement when multiple row actions are available, {0} is the comma-separated action texts
+TABLE_ROW_ACTIONS_LIST=Actions: {0}
+#XACT: Screenreader announcement for the overflow button in row actions
+TABLE_ROW_MORE_ACTIONS=More
+#XACT: Screenreader announcement for the highlight state "Negative"
+TABLE_HIGHLIGHT_NEGATIVE=Error
+#XACT: Screenreader announcement for the highlight state "Critical"
+TABLE_HIGHLIGHT_CRITICAL=Warning
+#XACT: Screenreader announcement for the highlight state "Positive"
+TABLE_HIGHLIGHT_POSITIVE=Success
+#XACT: Screenreader announcement for the highlight state "Information"
+TABLE_HIGHLIGHT_INFORMATION=Information
+#XACT: Screenreader announcement for the highlight state "Unread"
+TABLE_HIGHLIGHT_UNREAD=Unread
+#XACT: Screenreader one-time announcement when entering the table, {0} is the table description, {1} is the row count
+TABLE_ENTERING=Table {0} with {1} rows
+#XACT: Screenreader one-time announcement addition when table is multiselectable
+TABLE_ENTERING_MULTI_SELECTABLE=multiselectable
+#XACT: Screenreader one-time announcement for selected items count, {0} is the count
+TABLE_ENTERING_SELECTED={0} rows selected
+#XACT: Screenreader announcement when a row is selected
+TABLE_ROW_SELECTED_LIVE=Selected. {0} rows selected
+#XACT: Screenreader announcement when a row is not selected
+TABLE_ROW_NOT_SELECTED_LIVE=Not Selected. {0} rows selected
#XTOL: Tooltip of the header row checkbox to select all rows in the table
TABLE_SELECT_ALL_ROWS=Select All Rows
#XTOL: Tooltip of the header row checkbox to deselect all rows in the table
diff --git a/packages/main/src/themes/TableRow.css b/packages/main/src/themes/TableRow.css
index 0cfb7d2c8a63..2f71284cb15e 100644
--- a/packages/main/src/themes/TableRow.css
+++ b/packages/main/src/themes/TableRow.css
@@ -116,4 +116,78 @@
#actions-cell:has(+ #navigated-cell) {
inset-inline-end: var(--_ui5_table_navigated_cell_width);
+}
+
+/* highlight */
+#highlight-cell {
+ position: sticky;
+ inset-inline-start: 0;
+ z-index: 1;
+ background-color: inherit;
+ overflow: visible;
+ grid-row: 1 / -1;
+ min-width: 0;
+ padding: 0;
+}
+
+#highlight {
+ position: absolute;
+ inset: 0;
+ top: -1px;
+}
+
+:host([highlight="Negative"]) #highlight {
+ background: var(--sapErrorBorderColor);
+}
+
+:host([highlight="Critical"]) #highlight {
+ background: var(--sapWarningBorderColor);
+}
+
+:host([highlight="Positive"]) #highlight {
+ background: var(--sapSuccessBorderColor);
+}
+
+:host([highlight="Information"]) #highlight {
+ background: var(--sapInformationBorderColor);
+}
+
+:host([tabindex]:focus) #highlight {
+ transform: translateX(var(--_ui5_table_highlight_width));
+ top: 1px;
+ bottom: 2px;
+ right: -1px;
+}
+
+:host([tabindex]:focus) #highlight:dir(rtl) {
+ transform: translateX(calc(var(--_ui5_table_highlight_width) * -1));
+ left: -1px;
+}
+
+:host([tabindex]:focus) #highlight-cell {
+ clip-path: inset(var(--sapContent_FocusWidth) calc(var(--_ui5_table_highlight_width) * -1) var(--sapContent_FocusWidth) var(--sapContent_FocusWidth));
+}
+
+:host([tabindex]:focus) #highlight-cell:dir(rtl) {
+ clip-path: inset(var(--sapContent_FocusWidth) var(--sapContent_FocusWidth) var(--sapContent_FocusWidth) calc(var(--_ui5_table_highlight_width) * -1));
+}
+
+#highlight-cell + #selection-cell {
+ inset-inline-start: var(--_ui5_table_highlight_width);
+}
+
+:host([navigated]) #highlight-cell ~ #popin-cell {
+ grid-column: 2 / -2;
+}
+
+:host([navigated]) #highlight-cell ~ #selection-cell ~ #popin-cell {
+ grid-column: 3 / -2;
+}
+
+#highlight-cell ~ #popin-cell {
+ grid-column-start: 2;
+}
+
+#highlight-cell ~ #selection-cell ~ #popin-cell {
+ grid-column-start: 3;
}
\ No newline at end of file
diff --git a/packages/main/src/themes/TableRowBase.css b/packages/main/src/themes/TableRowBase.css
index cdb18889cbc7..11fbed172aa5 100644
--- a/packages/main/src/themes/TableRowBase.css
+++ b/packages/main/src/themes/TableRowBase.css
@@ -37,6 +37,7 @@
padding: 0;
inset-inline-start: 0;
min-width: auto;
+ justify-content: center;
}
#actions-cell {
diff --git a/packages/main/src/themes/base/Table-parameters.css b/packages/main/src/themes/base/Table-parameters.css
index ff0cfe9c8d13..97914214c233 100644
--- a/packages/main/src/themes/base/Table-parameters.css
+++ b/packages/main/src/themes/base/Table-parameters.css
@@ -2,6 +2,7 @@
--_ui5_table_cell_valign: center;
--_ui5_table_cell_min_width: 2.75rem;
--_ui5_table_navigated_cell_width: 0.25rem;
+ --_ui5_table_highlight_width: 0.1875rem;
--_ui5_first_table_cell_horizontal_padding: 1rem;
--_ui5_table_cell_horizontal_padding: 0.5rem;
--_ui5_table_cell_vertical_padding: 0.25rem;
diff --git a/packages/main/test/pages/Table_Acc_Advanced.html b/packages/main/test/pages/Table_Acc_Advanced.html
new file mode 100644
index 000000000000..4a1256f175eb
--- /dev/null
+++ b/packages/main/test/pages/Table_Acc_Advanced.html
@@ -0,0 +1,484 @@
+
+
+
+
+
+
+ Advanced Accessibility Test Page for Table
+
+
+
+
+
+
+
+
+ Advanced ACC Test Page - Custom Announcements
+
+
+
+
+
+
+ Selection Mode
+
+ Multi (SelectAll)
+ Multi (ClearAll)
+ Single
+ None
+
+
+
+ Identifier Column
+ Enabled
+
+
+ Row Highlights
+
+ Mixed
+ All None
+ All Positive
+ All Critical
+ All Negative
+ All Information
+
+
+
+ Row Actions
+
+ Mixed
+ None
+ Nav Only
+ Edit + Nav
+ Edit + Delete + Nav
+ Edit + Delete + Copy + Nav
+
+
+
+ Row Action Count
+
+
+
+ Interactive Rows
+
+ Mixed (original)
+ All Interactive
+ None Interactive
+
+
+
+
+
+
+
+
+ Sales Orders (6)
+
+
+
+
+
+
+
+ Document Number *
+ Company
+ City
+ Contact Person
+ Net Amount
+
+
+
+
+ 305382373556494
+ SAP SE
+ Walldorf
+ Max Mustermann
+ 1,250.00 EUR
+
+
+
+
+
+ 482910374829103
+ Acme Corp
+ New York
+ John Smith
+ 3,780.50 USD
+
+
+
+
+
+
+ 938271649382716
+ TechGlobal Ltd
+ London
+ Jane Williams
+ 890.00 GBP
+
+
+
+
+
+
+
+ 112233445566778
+ DataSoft GmbH
+ Berlin
+ Hans Weber
+ 5,200.00 EUR
+
+
+
+
+ 776655443322110
+ CloudFirst Inc
+ San Francisco
+ Alice Johnson
+ 12,340.00 USD
+
+
+
+
+
+ 554433221100998
+ OmniTech SA
+ Paris
+ Pierre Dupont
+ 2,100.75 EUR
+
+
+
+
+
+
+
+
+
+ Custom Announcement (ariaLabelledBy): —
+
+
+ Live Region (InvisibleMessage): —
+
+
+
+
+
+
+
+
First focus on any row (entering text prepended to custom announcement):
+
"Table Sales Orders (6) with 6 rows , multiselectable . Document Number ... . Row . ... . X of 7"
+
+
Row 1 (no highlight, nav action):
+
"Document Number 305382373556494 . Row . Action: Navigate to Details . 2 of 7"
+
+
Row 2 (Error + "Cancelled", delete + nav):
+
"Document Number 482910374829103 . Row . Error . Cancelled . Actions: Delete, Navigate to Details . 3 of 7"
+
+
Row 3 (Warning + "Return Initiated", edit + delete + nav = overflow with count=2):
+
"Document Number 938271649382716 . Row . Warning . Return Initiated . Actions: Edit, More, Navigate to Details . 4 of 7"
+
+
Row 4 (Success + "Delivered", no actions, not interactive):
+
"Document Number 112233445566778 . Row . Success . Delivered . 5 of 7"
+
+
Row 5 (Information + "Under Review", edit action):
+
"Document Number 776655443322110 . Row . Information . Under Review . Action: Edit . 6 of 7"
+
+
Row 6 (navigated, nav action):
+
"Document Number 554433221100998 . Row . Action: Navigate to Details . 7 of 7"
+
+
Selection toggle (InvisibleMessage):
+
"Selected. 1 rows selected" / "Not Selected. 0 rows selected"
+
+
+
+
+
+
+