Skip to content

Commit 88370a6

Browse files
committed
feat(table): implement table grouping with accessibility support
row-index for grouprows selection
1 parent dfe6751 commit 88370a6

11 files changed

Lines changed: 136 additions & 41 deletions

packages/main/cypress/specs/TableGroupRow.cy.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,68 @@ describe("Table - Group Rows", () => {
156156
cy.get("#dynamicGroup").should("have.attr", "aria-level", "1");
157157
});
158158

159+
it("should set aria-rowindex on group rows and data rows", () => {
160+
mountGroupedTable();
161+
162+
// Header row is aria-rowindex="1" (set by TableHeaderRow)
163+
// group1 is rows[0] → index 0 + 2 = 2
164+
cy.get("#group1").should("have.attr", "aria-rowindex", "2");
165+
// row1 is rows[1] → index 1 + 2 = 3
166+
cy.get("#row1").should("have.attr", "aria-rowindex", "3");
167+
// row2 is rows[2] → index 2 + 2 = 4
168+
cy.get("#row2").should("have.attr", "aria-rowindex", "4");
169+
// group2 is rows[3] → index 3 + 2 = 5
170+
cy.get("#group2").should("have.attr", "aria-rowindex", "5");
171+
// row3 is rows[4] → index 4 + 2 = 6
172+
cy.get("#row3").should("have.attr", "aria-rowindex", "6");
173+
});
174+
175+
it("should reset row alternation after each group header row", () => {
176+
cy.mount(
177+
<Table id="table" alternateRowColors>
178+
<TableHeaderRow slot="headerRow">
179+
<TableHeaderCell width="200px">City</TableHeaderCell>
180+
<TableHeaderCell width="200px">Country</TableHeaderCell>
181+
</TableHeaderRow>
182+
<TableGroupRow id="group1">
183+
<Text>Country: Germany</Text>
184+
</TableGroupRow>
185+
<TableRow id="rowDE1" rowKey="de1">
186+
<TableCell><Text>Berlin</Text></TableCell>
187+
<TableCell><Text>Germany</Text></TableCell>
188+
</TableRow>
189+
<TableRow id="rowDE2" rowKey="de2">
190+
<TableCell><Text>Munich</Text></TableCell>
191+
<TableCell><Text>Germany</Text></TableCell>
192+
</TableRow>
193+
<TableRow id="rowDE3" rowKey="de3">
194+
<TableCell><Text>Hamburg</Text></TableCell>
195+
<TableCell><Text>Germany</Text></TableCell>
196+
</TableRow>
197+
<TableGroupRow id="group2">
198+
<Text>Country: France</Text>
199+
</TableGroupRow>
200+
<TableRow id="rowFR1" rowKey="fr1">
201+
<TableCell><Text>Paris</Text></TableCell>
202+
<TableCell><Text>France</Text></TableCell>
203+
</TableRow>
204+
<TableRow id="rowFR2" rowKey="fr2">
205+
<TableCell><Text>Lyon</Text></TableCell>
206+
<TableCell><Text>France</Text></TableCell>
207+
</TableRow>
208+
</Table>
209+
);
210+
211+
// After group1, alternation resets: rowDE1 → alternate (0), rowDE2 → not (1), rowDE3 → alternate (2)
212+
cy.get("#rowDE1").should("have.attr", "_alternate");
213+
cy.get("#rowDE2").should("not.have.attr", "_alternate");
214+
cy.get("#rowDE3").should("have.attr", "_alternate");
215+
216+
// After group2, alternation resets again: rowFR1 → alternate (0), rowFR2 → not (1)
217+
cy.get("#rowFR1").should("have.attr", "_alternate");
218+
cy.get("#rowFR2").should("not.have.attr", "_alternate");
219+
});
220+
159221
it("should not throw with popin mode and group rows", () => {
160222
cy.mount(
161223
<Table id="table" overflowMode="Popin">

packages/main/src/Table.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/Acc
2121
import type DropIndicator from "./DropIndicator.js";
2222
import type TableHeaderRow from "./TableHeaderRow.js";
2323
import type TableRow from "./TableRow.js";
24+
import type TableGroupRow from "./TableGroupRow.js";
2425
import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
2526
import type { MoveEventDetail } from "@ui5/webcomponents-base/dist/util/dragAndDrop/DragRegistry.js";
2627
import type TableHeaderCell from "./TableHeaderCell.js";
@@ -279,7 +280,7 @@ class Table extends UI5Element {
279280
slots: true,
280281
},
281282
})
282-
rows!: DefaultSlot<TableRow>;
283+
rows!: DefaultSlot<TableRow | TableGroupRow>;
283284

284285
/**
285286
* Defines the header row of the component.
@@ -456,11 +457,17 @@ class Table extends UI5Element {
456457
}
457458

458459
onBeforeRendering(): void {
459-
this._renderNavigated = this.rows.some(row => row.navigated);
460-
[...this.headerRow, ...this.rows].forEach((row, index) => {
460+
let alternateIndex = 0;
461+
let ariaRowIndex = 2;
462+
this._renderNavigated = this.rows.some(row => "navigated" in row && row.navigated);
463+
[...this.headerRow, ...this.rows].forEach(row => {
461464
row._renderNavigated = this._renderNavigated;
462465
row._rowActionCount = this.rowActionCount;
463-
row._alternate = this.alternateRowColors && index % 2 === 0;
466+
row._alternate = this.alternateRowColors && alternateIndex % 2 === 0;
467+
alternateIndex = row.hasAttribute("ui5-table-group-row") ? 1 : alternateIndex + 1;
468+
if (!row.isHeaderRow()) {
469+
row.setAttribute("aria-rowindex", `${ariaRowIndex++}`);
470+
}
464471
});
465472

466473
this.style.setProperty("--ui5_grid_sticky_top", this.stickyTop);
@@ -663,6 +670,10 @@ class Table extends UI5Element {
663670
return widths.join(" ");
664671
}
665672

673+
get _rows(): TableRow[] {
674+
return this.rows.filter((row): row is TableRow => row.hasAttribute("ui5-table-row"));
675+
}
676+
666677
get _isRowSelectorRequired() {
667678
return this.rows.length > 0 && this._getSelection()?.isRowSelectorRequired();
668679
}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import TableCell from "./TableCell.js";
12
import type TableGroupRow from "./TableGroupRow.js";
23

34
export default function TableGroupRowTemplate(this: TableGroupRow) {
45
return (
5-
<div id="group-cell"
6-
role="gridcell"
6+
<TableCell id="group-cell"
77
aria-colindex={1}
88
aria-colspan={this._ariaColSpan}
99
>
1010
<slot></slot>
11-
</div>
11+
</TableCell>
1212
);
1313
}

packages/main/src/TableRow.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,10 @@ class TableRow extends TableRowBase<TableCell> {
126126

127127
onBeforeRendering() {
128128
super.onBeforeRendering();
129-
this.ariaRowIndex = (this.role === "row") ? `${this._rowIndex + 2}` : null;
130-
if (this._table?._hasGroupRows) {
131-
this.setAttribute("aria-level", "2");
132-
} else {
133-
this.removeAttribute("aria-level");
129+
if (this.position !== undefined) {
130+
this.setAttribute("aria-rowindex", `${this.position + 2}`);
134131
}
132+
toggleAttribute(this, "aria-level", !!this._table?._hasGroupRows, "2");
135133
toggleAttribute(this, "draggable", this.movable, "true");
136134
toggleAttribute(this, "_interactive", this._isInteractive);
137135
toggleAttribute(this, "_alternate", this._alternate);
@@ -207,14 +205,8 @@ class TableRow extends TableRowBase<TableCell> {
207205
return this.cells.some(c => c._popin && !c._popinHidden);
208206
}
209207

210-
get _rowIndex() {
211-
if (this.position !== undefined) {
212-
return this.position;
213-
}
214-
if (this._table) {
215-
return this._table.rows.indexOf(this);
216-
}
217-
return -1;
208+
override _getRowIndex(): number {
209+
return this.position ?? super._getRowIndex();
218210
}
219211

220212
get _hasOverflowActions() {

packages/main/src/TableRowBase.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ abstract class TableRowBase<TCell extends TableCellBase = TableCellBase> extends
6666
return false;
6767
}
6868

69+
_getRowIndex(): number {
70+
if (this._table) {
71+
return (this._table.rows as TableRowBase[]).indexOf(this);
72+
}
73+
return -1;
74+
}
75+
6976
_onSelectionChange() {
7077
const tableSelection = this._tableSelection!;
7178
const selected = tableSelection.isMultiSelectable() ? !this._isSelected : true;

packages/main/src/TableSelection.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement
77
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
88
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
99
import TableSelectionMode from "./types/TableSelectionMode.js";
10-
import { isSelectionCell, isHeaderSelectionCell, findRowInPath } from "./TableUtils.js";
10+
import {
11+
isSelectionCell,
12+
isHeaderSelectionCell,
13+
findRowInPath,
14+
} from "./TableUtils.js";
1115
import type Table from "./Table.js";
1216
import type { ITableFeature } from "./Table.js";
1317
import type TableRow from "./TableRow.js";
@@ -143,8 +147,8 @@ class TableSelection extends UI5Element implements ITableFeature {
143147
return undefined;
144148
}
145149

146-
getRowKey(row: TableRow): string {
147-
return row.rowKey || "";
150+
getRowKey(row: TableRowBase): string {
151+
return "rowKey" in row ? (row.rowKey as string) || "" : "";
148152
}
149153

150154
isSelected(row: TableRowBase): boolean {
@@ -166,7 +170,7 @@ class TableSelection extends UI5Element implements ITableFeature {
166170
}
167171

168172
const selectedArray = this.selectedAsArray;
169-
return this._table.rows.some(row => {
173+
return this._table._rows.some(row => {
170174
const rowKey = this.getRowKey(row);
171175
return selectedArray.includes(rowKey);
172176
});
@@ -178,7 +182,7 @@ class TableSelection extends UI5Element implements ITableFeature {
178182
}
179183

180184
const selectedArray = this.selectedAsArray;
181-
return this._table.rows.every(row => {
185+
return this._table._rows.every(row => {
182186
const rowKey = this.getRowKey(row);
183187
return selectedArray.includes(rowKey);
184188
});
@@ -229,7 +233,7 @@ class TableSelection extends UI5Element implements ITableFeature {
229233

230234
_selectHeaderRow(selected: boolean) {
231235
const selectedSet = this.selectedAsSet;
232-
this._table!.rows.forEach(row => {
236+
this._table!._rows.forEach(row => {
233237
const rowKey = this.getRowKey(row);
234238
selectedSet[selected ? "add" : "delete"](rowKey);
235239
});
@@ -312,8 +316,8 @@ class TableSelection extends UI5Element implements ITableFeature {
312316

313317
if (e.shiftKey && this._rangeSelection?.isMouse) {
314318
const startRow = this._rangeSelection.rows[0];
315-
const startIndex = this._table.rows.indexOf(startRow);
316-
const endIndex = this._table.rows.indexOf(row);
319+
const startIndex = this._table._rows.indexOf(startRow);
320+
const endIndex = this._table._rows.indexOf(row);
317321

318322
const selectionState = this.isSelected(startRow);
319323

@@ -369,10 +373,11 @@ class TableSelection extends UI5Element implements ITableFeature {
369373
if (shouldReverseSelection) {
370374
this._reverseRangeSelection();
371375
} else {
372-
const rowIndex = this._table!.rows.indexOf(targetRow);
376+
const rows = this._table!._rows;
377+
const rowIndex = rows.indexOf(targetRow);
373378
const [startIndex, endIndex] = [rowIndex, rowIndex - change].sort((a, b) => a - b);
374379

375-
selectionChanged = this._table?.rows.slice(startIndex, endIndex + 1).reduce((changed, row) => {
380+
selectionChanged = rows.slice(startIndex, endIndex + 1).reduce((changed, row) => {
376381
const isRowNotInSelection = !this._rangeSelection?.rows.includes(row);
377382
const isRowSelectionDifferent = this.isSelected(row) !== this._rangeSelection!.selected;
378383

packages/main/src/TableSelectionBase.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ abstract class TableSelectionBase extends UI5Element implements ITableFeature {
142142
*/
143143
getRowByKey(rowKey: string): TableRow | undefined {
144144
if (this._table && rowKey) {
145-
return this._table.rows.find(row => this.getRowKey(row) === rowKey);
145+
return this._table._rows.find(row => this.getRowKey(row) === rowKey);
146146
}
147147
}
148148

packages/main/src/TableSelectionMulti.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ class TableSelectionMulti extends TableSelectionBase {
108108
return;
109109
}
110110

111-
const tableRows = row.isHeaderRow() ? this._table!.rows : [row as TableRow];
111+
const tableRows = row.isHeaderRow() ? this._table!._rows : [row as TableRow];
112112
const selectedSet = this.getSelectedAsSet();
113113
const selectionChanged = tableRows.reduce((selectedSetChanged, tableRow) => {
114114
const rowKey = this.getRowKey(tableRow);
@@ -133,7 +133,7 @@ class TableSelectionMulti extends TableSelectionBase {
133133
* @public
134134
*/
135135
getSelectedRows(): TableRow[] {
136-
return this._table ? this._table.rows.filter(row => this.isSelected(row)) : [];
136+
return this._table ? this._table._rows.filter(row => this.isSelected(row)) : [];
137137
}
138138

139139
/**
@@ -145,7 +145,7 @@ class TableSelectionMulti extends TableSelectionBase {
145145
}
146146

147147
const selectedSet = this.getSelectedAsSet();
148-
return this._table.rows.every(row => {
148+
return this._table._rows.every(row => {
149149
const rowKey = this.getRowKey(row);
150150
return selectedSet.has(rowKey);
151151
});
@@ -253,8 +253,8 @@ class TableSelectionMulti extends TableSelectionBase {
253253

254254
if (e.shiftKey && this._rangeSelection?.isMouse) {
255255
const startRow = this._rangeSelection.rows[0];
256-
const startIndex = this._table.rows.indexOf(startRow);
257-
const endIndex = this._table.rows.indexOf(row);
256+
const startIndex = this._table._rows.indexOf(startRow);
257+
const endIndex = this._table._rows.indexOf(row);
258258

259259
// Set checkbox to the selection state of the start row (if it is selected)
260260
const selectionState = this.isSelected(startRow);
@@ -311,11 +311,12 @@ class TableSelectionMulti extends TableSelectionBase {
311311
if (shouldReverseSelection) {
312312
this._reverseRangeSelection();
313313
} else {
314-
const rowIndex = this._table!.rows.indexOf(targetRow);
314+
const rows = this._table!._rows;
315+
const rowIndex = rows.indexOf(targetRow);
315316
const [startIndex, endIndex] = [rowIndex, rowIndex - change].sort((a, b) => a - b);
316317
const selectedSet = this.getSelectedAsSet();
317318

318-
selectionChanged = this._table?.rows.slice(startIndex, endIndex + 1).reduce((changed, row) => {
319+
selectionChanged = rows.slice(startIndex, endIndex + 1).reduce((changed, row) => {
319320
const isRowNotInSelection = !this._rangeSelection?.rows.includes(row);
320321
const isRowSelectionDifferent = this.isSelected(row) !== this._rangeSelection!.selected;
321322

packages/main/src/TableSelectionSingle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class TableSelectionSingle extends TableSelectionBase {
6262
* @public
6363
*/
6464
getSelectedRow(): TableRow | undefined {
65-
return this._table?.rows.find(row => this.isSelected(row));
65+
return this._table?._rows.find(row => this.isSelected(row));
6666
}
6767
}
6868

packages/main/src/TableVirtualizer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ class TableVirtualizer extends UI5Element implements ITableFeature {
218218
return;
219219
}
220220

221-
const firstRow = this._table.rows[0];
221+
const firstRow = this._table._rows[0];
222222
if (firstRow && firstRow.position !== undefined && firstRow.position > 0) {
223223
const transform = firstRow.position * this.rowHeight;
224224
return `translateY(${transform}px)`;
@@ -238,7 +238,7 @@ class TableVirtualizer extends UI5Element implements ITableFeature {
238238
}
239239

240240
let scrollTopChange = 0;
241-
const rows = this._table.rows;
241+
const rows = this._table._rows;
242242
const firstRow = rows[0];
243243
const lastRow = rows[rows.length - 1];
244244
const hasDataBeforeFirstRow = firstRow.position !== 0;

0 commit comments

Comments
 (0)