From e378e51b854ba60b87afa1cb896e93ab24f9e810 Mon Sep 17 00:00:00 2001 From: Cahit Guerguec Date: Mon, 13 Oct 2025 07:50:09 +0200 Subject: [PATCH 1/2] feat(ui5-table): alternateRowColors property added In current design it is available only for regular state of the rows. Fixes: #11597 Accessibility improvements for the growing button. Although announcement is OK a button should not placed in a grid. Fixes: #11143 --- packages/main/cypress/specs/Table.cy.tsx | 43 +++++++- .../specs/TableCustomAnnouncement.cy.tsx | 102 +++++++++++++++++- .../main/cypress/specs/TableGrowing.cy.tsx | 3 +- packages/main/src/Table.ts | 13 ++- packages/main/src/TableCellBase.ts | 8 +- packages/main/src/TableCustomAnnouncement.ts | 5 + packages/main/src/TableHeaderCell.ts | 2 +- packages/main/src/TableHeaderRowTemplate.tsx | 17 ++- packages/main/src/TableRow.ts | 6 +- packages/main/src/TableRowBase.ts | 5 +- packages/main/src/TableRowTemplate.tsx | 6 +- packages/main/src/TableTemplate.tsx | 6 +- .../main/src/i18n/messagebundle.properties | 6 +- packages/main/src/themes/TableHeaderRow.css | 1 + packages/main/src/themes/TableRow.css | 20 ++-- .../main/src/themes/base/Table-parameters.css | 4 + packages/main/test/pages/Table.html | 4 +- 17 files changed, 207 insertions(+), 44 deletions(-) diff --git a/packages/main/cypress/specs/Table.cy.tsx b/packages/main/cypress/specs/Table.cy.tsx index 6b78b0f37ff2..501595e4c64c 100644 --- a/packages/main/cypress/specs/Table.cy.tsx +++ b/packages/main/cypress/specs/Table.cy.tsx @@ -3,6 +3,7 @@ import TableHeaderRow from "../../src/TableHeaderRow.js"; import TableCell from "../../src/TableCell.js"; import TableRow from "../../src/TableRow.js"; import TableSelectionMulti from "../../src/TableSelectionMulti.js"; +import TableSelectionSingle from "../../src/TableSelectionSingle.js"; import TableHeaderCell from "../../src/TableHeaderCell.js"; import TableHeaderCellActionAI from "../../src/TableHeaderCellActionAI.js"; import Label from "../../src/Label.js"; @@ -249,6 +250,47 @@ describe("Table - Rendering", () => { // 2fr is being ignored checkWidth("#colD", 48); }); + + it("should alternate rows", () => { + cy.mount( + + + + ColumnA + + + R1C1 + + + R2C1 + + + R3C1 + + + R4C1 + +
+ ); + + cy.get("#table1").then($table => { + const rows = $table.find("[ui5-table-row]").get(); + const rowBackgrounds = rows.map(row => getComputedStyle(row).backgroundColor); + expect(rowBackgrounds[0]).to.not.equal(rowBackgrounds[1]); + expect(rowBackgrounds[1]).to.not.equal(rowBackgrounds[2]); + expect(rowBackgrounds[2]).to.not.equal(rowBackgrounds[3]); + expect(rowBackgrounds[0]).to.equal(rowBackgrounds[2]); + expect(rowBackgrounds[1]).to.equal(rowBackgrounds[3]); + }); + + cy.get("#selection").invoke("prop", "selected", "r2"); cy.wait(50); + cy.get("#table1").then($table => { + const rows = $table.find("[ui5-table-row]").get(); + const rowBackgrounds = rows.map(row => getComputedStyle(row).backgroundColor); + expect(rowBackgrounds[1]).to.not.equal(rowBackgrounds[3]); + expect(rowBackgrounds[0]).to.equal(rowBackgrounds[2]); + }); + }); }); describe("Table - Popin Mode", () => { @@ -994,7 +1036,6 @@ describe("Table - HeaderCell", () => { cy.get("@headerCell2").should("not.have.attr", "aria-sort"); cy.get("@table").invoke("css", "width", "250px"); - // eslint-disable-next-line cypress/no-unnecessary-waiting cy.wait(50); cy.get("@row1").find("ui5-table-cell[_popin]").as("row1popins"); diff --git a/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx index 55d6aa98edd4..2029faacd058 100644 --- a/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx +++ b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx @@ -33,6 +33,7 @@ const { TABLE_COLUMN_HEADER_ROW: { defaultText: COLUMN_HEADER_ROW }, TABLE_ROW_SELECTED: { defaultText: SELECTED }, TABLE_ROW_NAVIGABLE: { defaultText: NAVIGABLE }, + TABLE_ROW_NAVIGATED: { defaultText: NAVIGATED }, TABLE_ROW_ACTIVE: { defaultText: ACTIVE }, } = Translations; @@ -48,7 +49,7 @@ describe("Cell Custom Announcement - More details", () => {
Header3
- + @@ -73,6 +74,7 @@ describe("Cell Custom Announcement - More details", () => { cy.get("@row1").find("#row1-input1").as("row1Input1"); cy.get("@row1").find("#row1-input2").as("row1Input2"); cy.get("@row1").find("#row1-div").as("row1Div"); + cy.get("@row1").shadow().find("#navigated-cell").as("row1NavigatedCell"); }); function checkAnnouncement(expectedText: string, focusAgain = false) { @@ -88,14 +90,23 @@ describe("Cell Custom Announcement - More details", () => { it("should announce table cells", () => { cy.get("@row1").realClick(); // row focused + cy.focused().should("have.attr", "aria-rowindex", "2") + .should("have.attr", "role", "row"); + cy.realPress("ArrowRight"); // selection cell focused checkAnnouncement(""); + cy.focused().should("have.attr", "aria-colindex", "1") + .should("have.attr", "role", "gridcell"); cy.realPress("ArrowRight"); // first cell focused checkAnnouncement("Row1Cell1"); + cy.focused().should("have.attr", "aria-colindex", "2") + .should("have.attr", "role", "gridcell"); cy.realPress("ArrowRight"); // second cell focused checkAnnouncement(CONTAINS_CONTROL); + cy.focused().should("have.attr", "aria-colindex", "3") + .should("have.attr", "role", "gridcell"); cy.get("@row1Input2").invoke("removeAttr", "hidden"); checkAnnouncement(CONTAINS_CONTROLS, true); @@ -105,6 +116,8 @@ describe("Cell Custom Announcement - More details", () => { cy.realPress("ArrowRight"); // third cell focused checkAnnouncement(EMPTY); + cy.focused().should("have.attr", "aria-colindex", "4") + .should("have.attr", "role", "gridcell"); cy.get("@row1Div").invoke("attr", "tabindex", "0"); cy.get("@row1Div").invoke("css", "width", "150px"); @@ -114,6 +127,8 @@ describe("Cell Custom Announcement - More details", () => { cy.realPress("ArrowRight"); // fourth cell focused checkAnnouncement(`Row1Cell3 . ${CONTAINS_CONTROL}`); + cy.focused().should("have.attr", "aria-colindex", "5") + .should("have.attr", "role", "gridcell"); cy.document().then((doc) => { const row1Button = doc.getElementById("row1-button") as Button; @@ -130,8 +145,10 @@ describe("Cell Custom Announcement - More details", () => { cy.get("@row1Button").invoke("attr", "data-ui5-table-acc-text", "Button with custom accessibility text"); checkAnnouncement(`Button with custom accessibility text . ${CONTAINS_CONTROL}`, true); - cy.realPress("ArrowRight"); // ROW_ACTIONS cell + cy.realPress("ArrowRight"); // Row actions cell checkAnnouncement(Table.i18nBundle.getText(MULTIPLE_ACTIONS, 2)); + cy.focused().should("have.attr", "aria-colindex", "6") + .should("have.attr", "role", "gridcell"); cy.get("#row1-edit-action").invoke("remove"); checkAnnouncement(ONE_ROW_ACTION, true); @@ -139,29 +156,49 @@ describe("Cell Custom Announcement - More details", () => { cy.get("#row1-add-action").invoke("remove"); checkAnnouncement(EMPTY, true); + cy.get("@row1NavigatedCell").should("have.attr", "role", "none") + .should("have.attr", "aria-hidden", "true"); + cy.realPress("Home"); // selection cell focused checkAnnouncement(""); }); it("should announce table header cells", () => { cy.get("@headerRow").realClick(); // header row focused + cy.focused().should("have.attr", "aria-rowindex", "1") + .should("have.attr", "role", "row"); + cy.realPress("ArrowRight"); // selection cell focused checkAnnouncement(""); + cy.focused().should("have.attr", "aria-colindex", "1") + .should("have.attr", "role", "columnheader"); cy.realPress("ArrowRight"); // first cell focused checkAnnouncement(`Header1 ${GENERATED_BY_AI} . ${CONTAINS_CONTROL}`); + cy.focused().should("have.attr", "aria-colindex", "2") + .should("have.attr", "role", "columnheader") + .should("have.attr", "aria-sort", "ascending"); cy.realPress("ArrowRight"); // second cell focused checkAnnouncement("Header2"); + cy.focused().should("have.attr", "aria-colindex", "3") + .should("have.attr", "role", "columnheader"); cy.realPress("ArrowRight"); // third cell focused checkAnnouncement("Header3"); + cy.focused().should("have.attr", "aria-colindex", "4") + .should("have.attr", "role", "columnheader"); cy.realPress("ArrowRight"); // forth cell focused checkAnnouncement(EMPTY); + cy.focused().should("have.attr", "aria-colindex", "5") + .should("have.attr", "role", "columnheader") + .should("have.attr", "aria-sort", "descending"); - cy.realPress("ArrowRight"); // forth cell focused + cy.realPress("ArrowRight"); // row action focused checkAnnouncement(ROW_ACTIONS); + cy.focused().should("have.attr", "aria-colindex", "6") + .should("have.attr", "role", "columnheader"); cy.realPress("Home"); // selection cell focused checkAnnouncement(""); @@ -217,6 +254,8 @@ describe("Row Custom Announcement - Less details", () => { cy.get("#table0").shadow().find("#table").as("innerTable"); cy.get("@rows").first().as("row1"); cy.get("@row1").find("#row1-button").as("row1Button"); + cy.get("@row1").find("ui5-table-cell").as("row1Cells"); + cy.get("@headerRow").find("ui5-table-header-cell").first().as("headerRowCells"); cy.document().then((doc) => { const header1Label = doc.getElementById("Header1Label") as Label; @@ -257,6 +296,8 @@ describe("Row Custom Announcement - Less details", () => { 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"); cy.get("#selection").invoke("attr", "selected", ""); checkAnnouncement(`Row . 2 of 2 . ${NAVIGABLE}`, true); @@ -277,17 +318,50 @@ describe("Row Custom Announcement - Less details", () => { cy.get("#row1-nav-action").invoke("remove"); cy.get("#row1-add-action").invoke("remove"); - checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H4Popin . C4 Button C4Button`, true, "equal"); + checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H4Popin . C4 Button C4Button . ${NAVIGATED}`, true, "equal"); cy.realPress("ArrowRight"); // selection cell focused checkAnnouncement(""); + cy.focused().should("have.attr", "aria-colindex", "1") + .should("have.attr", "role", "gridcell"); + + cy.realPress("ArrowRight"); // first cell focused + checkAnnouncement("R1C1", false, "equal"); + cy.focused().should("have.attr", "aria-colindex", "2") + .should("have.attr", "role", "gridcell"); + + cy.realPress("ArrowRight"); // row action cell focused + checkAnnouncement(EMPTY, false, "equal"); + cy.focused().should("have.attr", "aria-colindex", "3") + .should("have.attr", "role", "gridcell"); cy.realPress("End"); // popin cell focused we need details checkAnnouncement(`H2 . ${CONTAINS_CONTROLS} . H4Popin ${GENERATED_BY_AI} . ${CONTAINS_CONTROL} . C4 Button C4Button ${REQUIRED} ${DISABLED} ${READONLY} . ${CONTAINS_CONTROL}`); + cy.focused().should("have.attr", "aria-colindex", "4") + .should("have.attr", "role", "gridcell"); + + cy.get("@row1Cells").each(($cell, index) => { + if (index === 0) return; + cy.wrap($cell).should("have.attr", "_popin"); + cy.wrap($cell).should("not.have.attr", "role"); + cy.wrap($cell).should("not.have.attr", "aria-colindex"); + }); cy.realPress("Home"); // selection cell focused cy.realPress("Home"); // row focused + cy.get("#table0").invoke("css", "width", "1000px"); + checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button . ${NAVIGATED}`, true, "equal"); + cy.get("@row1Cells").each(($cell, index) => { + cy.wrap($cell).should("not.have.attr", "_popin"); + cy.wrap($cell).should("have.attr", "role", "gridcell"); + cy.wrap($cell).should("have.attr", "aria-colindex", `${index + 2}`); + }); + + cy.get("@row1").shadow().find("#navigated-cell").should("have.attr", "role", "none") + .should("have.attr", "aria-hidden", "true"); + + cy.get("@row1").invoke("prop", "navigated", false); checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button`, true, "equal"); cy.realPress("ArrowUp"); // header row focused @@ -319,5 +393,25 @@ describe("Row Custom Announcement - Less details", () => { cy.get("#table0").invoke("append", ''); checkAnnouncement(`${COLUMN_HEADER_ROW} . ${SELECTION} . H1 . H2 . H3 . H4`, true, "equal"); + + cy.get("#table0").invoke("css", "width", "301px"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${SELECTION} . H1`, true, "equal"); + cy.get("@headerRowCells").each(($cell, index) => { + if (index === 0) { + cy.wrap($cell).should("have.attr", "role", "columnheader"); + cy.wrap($cell).should("have.attr", "aria-colindex", "2"); + } else { + cy.wrap($cell).should("have.attr", "_popin"); + cy.wrap($cell).should("not.have.attr", "role"); + cy.wrap($cell).should("not.have.attr", "aria-colindex"); + } + }); + + cy.get("#table0").invoke("css", "width", "1000px"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${SELECTION} . H1 . H2 . H3 . H4`, true, "equal"); + cy.get("@headerRowCells").each(($cell, index) => { + cy.wrap($cell).should("have.attr", "role", "columnheader"); + cy.wrap($cell).should("have.attr", "aria-colindex", `${index + 2}`); + }); }); }); diff --git a/packages/main/cypress/specs/TableGrowing.cy.tsx b/packages/main/cypress/specs/TableGrowing.cy.tsx index 5fcd9a0f06c2..4371c613f20e 100644 --- a/packages/main/cypress/specs/TableGrowing.cy.tsx +++ b/packages/main/cypress/specs/TableGrowing.cy.tsx @@ -51,8 +51,7 @@ describe("TableGrowing - Button", () => { cy.get("[ui5-table") .shadow() .find("#growing-row") - .should("exist") - .should("have.attr", "aria-hidden", "true"); + .should("exist"); cy.get("[ui5-table-growing]") .shadow() diff --git a/packages/main/src/Table.ts b/packages/main/src/Table.ts index 3167e0915618..ee8bf5d11798 100644 --- a/packages/main/src/Table.ts +++ b/packages/main/src/Table.ts @@ -369,6 +369,16 @@ class Table extends UI5Element { @property({ type: Number }) rowActionCount = 0; + /** + * Determines whether the table rows are displayed with alternating background colors. + * + * @default false + * @since 2.17 + * @public + */ + @property({ type: Boolean }) + alternateRowColors = false; + /** * Defines the sticky top offset of the table, if other sticky elements outside of the table exist. */ @@ -436,9 +446,10 @@ class Table extends UI5Element { onBeforeRendering(): void { this._renderNavigated = this.rows.some(row => row.navigated); - [...this.headerRow, ...this.rows].forEach(row => { + [...this.headerRow, ...this.rows].forEach((row, index) => { row._renderNavigated = this._renderNavigated; row._rowActionCount = this.rowActionCount; + row._alternate = this.alternateRowColors && index % 2 === 0; }); this.style.setProperty(getScopedVarName("--ui5_grid_sticky_top"), this.stickyTop); diff --git a/packages/main/src/TableCellBase.ts b/packages/main/src/TableCellBase.ts index 13af6ba3ddf8..0976884644d4 100644 --- a/packages/main/src/TableCellBase.ts +++ b/packages/main/src/TableCellBase.ts @@ -2,7 +2,6 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; import { customElement, slot, property, i18n, } from "@ui5/webcomponents-base/dist/decorators.js"; -import { toggleAttribute } from "./TableUtils.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import TableCellBaseStyles from "./generated/themes/TableCellBase.css.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; @@ -45,19 +44,16 @@ abstract class TableCellBase extends UI5Element { @property({ type: Boolean, noAttribute: true }) _popinHidden = false; - protected ariaRole: string = "gridcell"; + ariaRole: string = "gridcell"; @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; onEnterDOM() { + !this.role && this.setAttribute("role", this.ariaRole); this.toggleAttribute("ui5-table-cell-base", true); } - onBeforeRendering() { - toggleAttribute(this, "role", !this._popin, this.ariaRole); - } - getFocusDomRef() { return this; } diff --git a/packages/main/src/TableCustomAnnouncement.ts b/packages/main/src/TableCustomAnnouncement.ts index c65a13d6922c..db60b4527b65 100644 --- a/packages/main/src/TableCustomAnnouncement.ts +++ b/packages/main/src/TableCustomAnnouncement.ts @@ -12,6 +12,7 @@ import { TABLE_ROW_SELECTED, TABLE_ROW_ACTIVE, TABLE_ROW_NAVIGABLE, + TABLE_ROW_NAVIGATED, TABLE_COLUMN_HEADER_ROW, TABLE_CELL_SINGLE_CONTROL, TABLE_CELL_MULTIPLE_CONTROLS, @@ -218,6 +219,10 @@ class TableCustomAnnouncement extends TableExtension { descriptions.push(row._actionCellAccText!); } + if (row._renderNavigated && row.navigated) { + descriptions.push(i18nBundle.getText(TABLE_ROW_NAVIGATED)); + } + updateInvisibleText(row, descriptions); } diff --git a/packages/main/src/TableHeaderCell.ts b/packages/main/src/TableHeaderCell.ts index 5b14efd2bcc6..0d4057d55853 100644 --- a/packages/main/src/TableHeaderCell.ts +++ b/packages/main/src/TableHeaderCell.ts @@ -131,7 +131,7 @@ class TableHeaderCell extends TableCellBase { @query("slot[name=action]") _actionSlot!: HTMLSlotElement; - protected ariaRole: string = "columnheader"; + ariaRole: string = "columnheader"; _popinWidth: number = 0; onBeforeRendering() { diff --git a/packages/main/src/TableHeaderRowTemplate.tsx b/packages/main/src/TableHeaderRowTemplate.tsx index 5d68ca6e639e..8835e75dd45f 100644 --- a/packages/main/src/TableHeaderRowTemplate.tsx +++ b/packages/main/src/TableHeaderRowTemplate.tsx @@ -45,27 +45,26 @@ export default function TableHeaderRowTemplate(this: TableHeaderRow, ariaColInde { this.cells.flatMap(cell => { if (cell._popin) { + cell.role = null; cell.ariaColIndex = null; return []; } - cell.ariaColIndex = `${ariaColIndex++}`; + + cell.role ??= cell.ariaRole; + cell.ariaColIndex = (cell.role === cell.ariaRole) ? `${ariaColIndex++}` : null; return []; })} { this._rowActionCount > 0 && - +
{this._i18nRowActions}
} { this._popinCells.length > 0 && - + +
{this._i18nRowPopin}
+
} ); diff --git a/packages/main/src/TableRow.ts b/packages/main/src/TableRow.ts index a6ae9c9c896e..ab8e7dd1b48e 100644 --- a/packages/main/src/TableRow.ts +++ b/packages/main/src/TableRow.ts @@ -125,10 +125,10 @@ class TableRow extends TableRowBase { onBeforeRendering() { super.onBeforeRendering(); - toggleAttribute(this, "aria-current", this._renderNavigated && this.navigated, "true"); - toggleAttribute(this, "_interactive", this._isInteractive); + this.ariaRowIndex = (this.role === "row") ? `${this._rowIndex + 2}` : null; toggleAttribute(this, "draggable", this.movable, "true"); - this.ariaRowIndex = `${this._rowIndex + 2}`; + toggleAttribute(this, "_interactive", this._isInteractive); + toggleAttribute(this, "_alternate", this._alternate); } async focus(focusOptions?: FocusOptions | undefined): Promise { diff --git a/packages/main/src/TableRowBase.ts b/packages/main/src/TableRowBase.ts index 62651401c561..d4c429c508c0 100644 --- a/packages/main/src/TableRowBase.ts +++ b/packages/main/src/TableRowBase.ts @@ -37,6 +37,9 @@ abstract class TableRowBase extends UI5Element { @property({ type: Boolean, noAttribute: true }) _renderNavigated = false; + @property({ type: Boolean, noAttribute: true }) + _alternate = false; + @query("#selection-cell") _selectionCell?: HTMLElement; @@ -47,7 +50,7 @@ abstract class TableRowBase extends UI5Element { static i18nBundle: I18nBundle; onEnterDOM() { - this.setAttribute("role", "row"); + !this.role && this.setAttribute("role", "row"); this.toggleAttribute("ui5-table-row-base", true); } diff --git a/packages/main/src/TableRowTemplate.tsx b/packages/main/src/TableRowTemplate.tsx index cfb6ec3b1957..5e44c7825df4 100644 --- a/packages/main/src/TableRowTemplate.tsx +++ b/packages/main/src/TableRowTemplate.tsx @@ -38,10 +38,13 @@ export default function TableRowTemplate(this: TableRow, ariaColIndex: number = { this.cells.flatMap(cell => { if (cell._popin) { + cell.role = null; cell.ariaColIndex = null; return []; } - cell.ariaColIndex = `${ariaColIndex++}`; + + cell.role ??= cell.ariaRole; + cell.ariaColIndex = (cell.role === cell.ariaRole) ? `${ariaColIndex++}` : null; return []; })} @@ -72,6 +75,7 @@ export default function TableRowTemplate(this: TableRow, ariaColIndex: number = diff --git a/packages/main/src/TableTemplate.tsx b/packages/main/src/TableTemplate.tsx index 16969179c5bc..1f9432b63cad 100644 --- a/packages/main/src/TableTemplate.tsx +++ b/packages/main/src/TableTemplate.tsx @@ -38,7 +38,7 @@ export default function TableTemplate(this: Table) { } { this.rows.length > 0 && this._getGrowing()?.hasGrowingComponent() && -