From d4d7899f66dfe2adaeb9cace1d0f403b27c26794 Mon Sep 17 00:00:00 2001 From: Cahit Guerguec Date: Wed, 15 Apr 2026 22:51:55 +0200 Subject: [PATCH] refactor(ui5-table): advanced custom announcement --- .../specs/TableCustomAnnouncement.cy.tsx | 247 ++++++++- packages/main/src/Table.ts | 10 + packages/main/src/TableCell.ts | 7 + packages/main/src/TableCustomAnnouncement.ts | 116 ++++- packages/main/src/TableHeaderCell.ts | 18 + packages/main/src/TableHeaderRowTemplate.tsx | 4 + packages/main/src/TableRow.ts | 91 ++++ packages/main/src/TableRowBase.ts | 3 + packages/main/src/TableRowTemplate.tsx | 10 + packages/main/src/TableSelectionBase.ts | 13 + packages/main/src/TableSelectionMulti.ts | 6 +- packages/main/src/TableSelectionSingle.ts | 5 +- .../main/src/i18n/messagebundle.properties | 28 +- packages/main/src/themes/TableRow.css | 74 +++ packages/main/src/themes/TableRowBase.css | 1 + .../main/src/themes/base/Table-parameters.css | 1 + .../main/test/pages/Table_Acc_Advanced.html | 484 ++++++++++++++++++ 17 files changed, 1102 insertions(+), 16 deletions(-) create mode 100644 packages/main/test/pages/Table_Acc_Advanced.html 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( + + + Document Number + 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"

+
+
+ + + + +