Skip to content

Commit 6202009

Browse files
authored
feat(table): add support for group rows (#13510)
Fixes: #10148
1 parent 1129ef2 commit 6202009

31 files changed

Lines changed: 857 additions & 89 deletions

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Separator from "../../src/TabSeparator.js"
77
import Table from "../../src/Table.js";
88
import TableCell from "../../src/TableCell.js";
99
import TableRow from "../../src/TableRow.js";
10+
import TableHeaderRow from "../../src/TableHeaderRow.js";
1011
import TableHeaderCell from "../../src/TableHeaderCell.js";
1112
import "@ui5/webcomponents-icons/dist/employee.js"
1213
import "@ui5/webcomponents-icons/dist/menu.js"
@@ -577,8 +578,10 @@ describe("TabContainer general interaction", () => {
577578
<TabContainer class="tabContainerNoContentPaddings" style="padding-left: 0px; padding-right: 0px;">
578579
<Tab icon="sap-icon://card" selected>
579580
<Table>
580-
<TableHeaderCell slot="default">Source</TableHeaderCell>
581-
<TableHeaderCell slot="default">Method</TableHeaderCell>
581+
<TableHeaderRow slot="headerRow">
582+
<TableHeaderCell>Source</TableHeaderCell>
583+
<TableHeaderCell>Method</TableHeaderCell>
584+
</TableHeaderRow>
582585
<TableRow>
583586
<TableCell>Cell 1</TableCell>
584587
<TableCell>Cell 2</TableCell>
@@ -603,8 +606,10 @@ describe("TabContainer general interaction", () => {
603606
</Tab>
604607
<Tab icon="sap-icon://employee">
605608
<Table>
606-
<TableHeaderCell slot="default">Source</TableHeaderCell>
607-
<TableHeaderCell slot="default">Method</TableHeaderCell>
609+
<TableHeaderRow slot="headerRow">
610+
<TableHeaderCell>Source</TableHeaderCell>
611+
<TableHeaderCell>Method</TableHeaderCell>
612+
</TableHeaderRow>
608613
<TableRow>
609614
<TableCell>Cell 3</TableCell>
610615
<TableCell>Cell 4</TableCell>
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import Table from "../../src/Table.js";
2+
import TableHeaderRow from "../../src/TableHeaderRow.js";
3+
import TableHeaderCell from "../../src/TableHeaderCell.js";
4+
import TableRow from "../../src/TableRow.js";
5+
import TableCell from "../../src/TableCell.js";
6+
import TableGroupRow from "../../src/TableGroupRow.js";
7+
import TableSelectionMulti from "../../src/TableSelectionMulti.js";
8+
import TableRowActionNavigation from "../../src/TableRowActionNavigation.js";
9+
import Text from "../../src/Text.js";
10+
11+
describe("Table - Group Rows", () => {
12+
function mountGroupedTable() {
13+
cy.mount(
14+
<Table id="table" accessible-name="Grouped Table">
15+
<TableHeaderRow slot="headerRow">
16+
<TableHeaderCell id="colA" width="200px">City</TableHeaderCell>
17+
<TableHeaderCell id="colB" width="200px">Country</TableHeaderCell>
18+
<TableHeaderCell id="colC" width="150px">Population</TableHeaderCell>
19+
</TableHeaderRow>
20+
<TableGroupRow id="group1">
21+
<Text>Country: Germany</Text>
22+
</TableGroupRow>
23+
<TableRow id="row1" rowKey="row-1">
24+
<TableCell><Text>Berlin</Text></TableCell>
25+
<TableCell><Text>Germany</Text></TableCell>
26+
<TableCell><Text>3,748,148</Text></TableCell>
27+
</TableRow>
28+
<TableRow id="row2" rowKey="row-2">
29+
<TableCell><Text>Munich</Text></TableCell>
30+
<TableCell><Text>Germany</Text></TableCell>
31+
<TableCell><Text>1,471,508</Text></TableCell>
32+
</TableRow>
33+
<TableGroupRow id="group2">
34+
<Text>Country: France</Text>
35+
</TableGroupRow>
36+
<TableRow id="row3" rowKey="row-3">
37+
<TableCell><Text>Paris</Text></TableCell>
38+
<TableCell><Text>France</Text></TableCell>
39+
<TableCell><Text>2,161,000</Text></TableCell>
40+
</TableRow>
41+
</Table>
42+
);
43+
}
44+
45+
it("should render group rows and data rows", () => {
46+
mountGroupedTable();
47+
48+
cy.get("[ui5-table-group-row]").should("have.length", 2);
49+
cy.get("[ui5-table-row]:not([ui5-table-group-row])").should("have.length", 3);
50+
cy.get("#group1").should("contain.text", "Country: Germany");
51+
cy.get("#group2").should("contain.text", "Country: France");
52+
});
53+
54+
it("should have aria-roledescription on group rows", () => {
55+
mountGroupedTable();
56+
57+
cy.get("#group1").should("have.attr", "aria-roledescription");
58+
cy.get("#group2").should("have.attr", "aria-roledescription");
59+
});
60+
61+
it("should use role=row on group rows", () => {
62+
mountGroupedTable();
63+
64+
cy.get("#group1").should("have.attr", "role", "row");
65+
cy.get("#table")
66+
.shadow()
67+
.find("#table")
68+
.should("have.attr", "role", "grid");
69+
});
70+
71+
it("should have group cell spanning all columns", () => {
72+
mountGroupedTable();
73+
74+
cy.get("#group1")
75+
.shadow()
76+
.find("#group-cell")
77+
.should("have.attr", "role", "gridcell")
78+
.and("have.attr", "aria-colindex", "1")
79+
.and("have.attr", "aria-colspan", "2");
80+
});
81+
82+
it("should set aria-rowindex sequentially on all rows", () => {
83+
mountGroupedTable();
84+
85+
cy.get("#group1").should("have.attr", "aria-rowindex", "2");
86+
cy.get("#row1").should("have.attr", "aria-rowindex", "3");
87+
cy.get("#row2").should("have.attr", "aria-rowindex", "4");
88+
cy.get("#group2").should("have.attr", "aria-rowindex", "5");
89+
cy.get("#row3").should("have.attr", "aria-rowindex", "6");
90+
});
91+
92+
it("should not be selectable", () => {
93+
cy.mount(
94+
<Table id="table">
95+
<TableSelectionMulti slot="features"></TableSelectionMulti>
96+
<TableHeaderRow slot="headerRow">
97+
<TableHeaderCell width="200px">City</TableHeaderCell>
98+
</TableHeaderRow>
99+
<TableGroupRow id="group1">
100+
<Text>Group</Text>
101+
</TableGroupRow>
102+
<TableRow id="row1" rowKey="row-1">
103+
<TableCell><Text>Berlin</Text></TableCell>
104+
</TableRow>
105+
</Table>
106+
);
107+
108+
cy.get("#group1").should("not.have.attr", "aria-selected");
109+
});
110+
111+
it("should not affect Select All behavior", () => {
112+
cy.mount(
113+
<Table id="table">
114+
<TableSelectionMulti slot="features" id="selection"></TableSelectionMulti>
115+
<TableHeaderRow slot="headerRow">
116+
<TableHeaderCell width="200px">City</TableHeaderCell>
117+
</TableHeaderRow>
118+
<TableGroupRow id="group1">
119+
<Text>Group</Text>
120+
</TableGroupRow>
121+
<TableRow id="row1" rowKey="row-1">
122+
<TableCell><Text>Berlin</Text></TableCell>
123+
</TableRow>
124+
<TableRow id="row2" rowKey="row-2">
125+
<TableCell><Text>Munich</Text></TableCell>
126+
</TableRow>
127+
</Table>
128+
);
129+
130+
// Select all via header checkbox
131+
cy.get("[ui5-table-header-row]")
132+
.shadow()
133+
.find("#selection-component")
134+
.realClick();
135+
136+
// Both data rows should be selected
137+
cy.get("#row1").should("have.attr", "aria-selected", "true");
138+
cy.get("#row2").should("have.attr", "aria-selected", "true");
139+
// Group row should NOT be selected
140+
cy.get("#group1").should("not.have.attr", "aria-selected");
141+
});
142+
143+
it("should reset row alternation after each group header row", () => {
144+
cy.mount(
145+
<Table id="table" alternateRowColors>
146+
<TableHeaderRow slot="headerRow">
147+
<TableHeaderCell width="200px">City</TableHeaderCell>
148+
</TableHeaderRow>
149+
<TableGroupRow id="group1">
150+
<Text>Group 1</Text>
151+
</TableGroupRow>
152+
<TableRow id="rowA" rowKey="a">
153+
<TableCell><Text>A</Text></TableCell>
154+
</TableRow>
155+
<TableRow id="rowB" rowKey="b">
156+
<TableCell><Text>B</Text></TableCell>
157+
</TableRow>
158+
<TableRow id="rowC" rowKey="c">
159+
<TableCell><Text>C</Text></TableCell>
160+
</TableRow>
161+
<TableGroupRow id="group2">
162+
<Text>Group 2</Text>
163+
</TableGroupRow>
164+
<TableRow id="rowD" rowKey="d">
165+
<TableCell><Text>D</Text></TableCell>
166+
</TableRow>
167+
<TableRow id="rowE" rowKey="e">
168+
<TableCell><Text>E</Text></TableCell>
169+
</TableRow>
170+
</Table>
171+
);
172+
173+
// After group1: rowA(1)=not, rowB(2)=alternate, rowC(3)=not
174+
cy.get("#rowA").should("not.have.attr", "_alternate");
175+
cy.get("#rowB").should("have.attr", "_alternate");
176+
cy.get("#rowC").should("not.have.attr", "_alternate");
177+
178+
// After group2: reset → rowD(1)=not, rowE(2)=alternate
179+
cy.get("#rowD").should("not.have.attr", "_alternate");
180+
cy.get("#rowE").should("have.attr", "_alternate");
181+
182+
// Group rows never get _alternate
183+
cy.get("#group1").should("not.have.attr", "_alternate");
184+
cy.get("#group2").should("not.have.attr", "_alternate");
185+
});
186+
187+
it("should not throw with popin mode and group rows", () => {
188+
cy.mount(
189+
<Table id="table" overflowMode="Popin">
190+
<TableHeaderRow slot="headerRow">
191+
<TableHeaderCell minWidth="300px">City</TableHeaderCell>
192+
<TableHeaderCell minWidth="200px">Country</TableHeaderCell>
193+
<TableHeaderCell minWidth="200px">Population</TableHeaderCell>
194+
</TableHeaderRow>
195+
<TableGroupRow id="group1">
196+
<Text>Group</Text>
197+
</TableGroupRow>
198+
<TableRow id="row1" rowKey="row-1">
199+
<TableCell><Text>Berlin</Text></TableCell>
200+
<TableCell><Text>Germany</Text></TableCell>
201+
<TableCell><Text>3,748,148</Text></TableCell>
202+
</TableRow>
203+
</Table>
204+
);
205+
206+
// Shrink to trigger popin
207+
cy.get("#table").invoke("css", "width", "300px");
208+
209+
// Should not throw — table and rows intact
210+
cy.get("#table").should("exist");
211+
cy.get("#group1").should("exist");
212+
cy.get("#row1").should("exist");
213+
214+
// Expand again
215+
cy.get("#table").invoke("css", "width", "800px");
216+
cy.get("#group1").should("contain.text", "Group");
217+
});
218+
219+
it("should be keyboard navigable as a single-cell row", () => {
220+
mountGroupedTable();
221+
222+
// Click on left edge to focus the row itself (not a cell inside)
223+
cy.get("#row1").click("left");
224+
cy.get("#row1").should("be.focused");
225+
226+
// Arrow up should land on the group row
227+
cy.get("#row1").type("{uparrow}");
228+
cy.get("#group1").should("be.focused");
229+
});
230+
231+
it("should not move focus on left/right arrow keys", () => {
232+
mountGroupedTable();
233+
234+
cy.get("#row1").click("left");
235+
cy.get("#row1").type("{uparrow}");
236+
cy.get("#group1").should("be.focused");
237+
238+
// Left/right should not move focus away from the group row
239+
cy.get("#group1").type("{leftarrow}");
240+
cy.get("#group1").should("be.focused");
241+
242+
cy.get("#group1").type("{rightarrow}");
243+
cy.get("#group1").should("be.focused");
244+
});
245+
246+
it("should preserve column position when navigating through a group row", () => {
247+
mountGroupedTable();
248+
249+
// Focus the second cell of row1
250+
cy.get("#row1").click("left");
251+
cy.get("#row1").type("{rightarrow}{rightarrow}");
252+
cy.get("#row1").children("[ui5-table-cell]").eq(1).should("be.focused");
253+
254+
// Navigate up to the group row
255+
cy.realPress("ArrowUp");
256+
cy.get("#group1").should("be.focused");
257+
258+
// Navigate back down — should return to the same column (2nd cell)
259+
cy.realPress("ArrowDown");
260+
cy.get("#row1").children("[ui5-table-cell]").eq(1).should("be.focused");
261+
});
262+
263+
it("should expose empty cells array even when children are slotted", () => {
264+
cy.mount(
265+
<Table id="table">
266+
<TableHeaderRow slot="headerRow">
267+
<TableHeaderCell width="200px">Col</TableHeaderCell>
268+
</TableHeaderRow>
269+
<TableGroupRow id="group1">
270+
<Text>Group with content</Text>
271+
</TableGroupRow>
272+
</Table>
273+
);
274+
275+
cy.get("#group1").then(($el) => {
276+
const groupRow = $el[0] as unknown as TableGroupRow;
277+
expect(groupRow.cells).to.have.length(0);
278+
});
279+
});
280+
281+
it("should not render actions cell when table has rowActionCount", () => {
282+
cy.mount(
283+
<Table id="table" rowActionCount={1}>
284+
<TableHeaderRow slot="headerRow">
285+
<TableHeaderCell width="200px">City</TableHeaderCell>
286+
</TableHeaderRow>
287+
<TableGroupRow id="group1">
288+
<Text>Group</Text>
289+
</TableGroupRow>
290+
<TableRow id="row1" rowKey="row-1">
291+
<TableCell><Text>Berlin</Text></TableCell>
292+
<TableRowActionNavigation slot="actions" interactive></TableRowActionNavigation>
293+
</TableRow>
294+
</Table>
295+
);
296+
297+
// Data row should have actions cell
298+
cy.get("#row1")
299+
.shadow()
300+
.find("#actions-cell")
301+
.should("exist");
302+
303+
// Group row should NOT have actions cell
304+
cy.get("#group1")
305+
.shadow()
306+
.find("#actions-cell")
307+
.should("not.exist");
308+
});
309+
});

packages/main/src/GridWalker.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ class GridWalker {
1717
}
1818

1919
left() {
20-
this.colPos = Math.max(this.getColPos() - 1, 0);
20+
const cellCount = this.grid[this.getRowPos()].length;
21+
if (cellCount > 1) {
22+
this.colPos = Math.max(this.getColPos() - 1, 0);
23+
}
2124
}
2225

2326
right() {
24-
this.colPos = Math.min(this.getColPos() + 1, this.grid[this.getRowPos()].length - 1);
27+
const cellCount = this.grid[this.getRowPos()].length;
28+
if (cellCount > 1) {
29+
this.colPos = Math.min(this.getColPos() + 1, cellCount - 1);
30+
}
2531
}
2632

2733
up() {

0 commit comments

Comments
 (0)