Skip to content

Commit 3d669be

Browse files
committed
feat(ui5-table-cell): merged property added
- New merged boolean property on ui5-table-cell - Row borders refactored: border-top per cell instead of border-bottom per row - First row top border / last row bottom border handled via :first-of-type / :last-of-type - Selection highlight uses box-shadow instead of border-bottom to avoid layout shifts - Table now uses inset-inline-end/start, the old left/right + :dir(rtl) removed - When merged, cell's top border becomes transparent and content is hidden - Merging is disabled on hover/focus, content reveals with an opacity transition - Merging is also disabled when the row enters popin mode - Implemented via CSS Space Toggle trick, no JS needed - When a selection column is present, its border also syncs with the first merged cell - New TableCell website sample added for merged cells - Partially fixes #7238 - CPOUIFTEAMB-2624
1 parent ca81ad3 commit 3d669be

17 files changed

Lines changed: 482 additions & 78 deletions

File tree

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

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import Title from "../../src/Title.js";
1313
import Slider from "../../src/Slider.js";
1414
import Button from "../../src/Button.js";
1515

16-
// Porting Table.spec.js (wdio tests) to cypress tests
17-
const ROLE_COLUMN_HEADER = "columnheader";
1816

1917
describe("Table - Rendering", () => {
2018
function checkWidth(id: string, expectedWidth: number) {
@@ -302,7 +300,7 @@ describe("Table - Popin Mode", () => {
302300
const roleCondition = shouldBePoppedIn || shouldBeHidden ? "not.have.attr" : "have.attr";
303301

304302
cy.wrap($cell)
305-
.should(roleCondition, "role", ROLE_COLUMN_HEADER);
303+
.should(roleCondition, "role", "columnheader");
306304
cy.get("ui5-table-header-row")
307305
.shadow()
308306
.find(`slot[name=default-${index + 1}]`)
@@ -1083,3 +1081,122 @@ describe("Table - HeaderCell", () => {
10831081
cy.get("@actionBclickTarget").should("have.attr", "tooltip", "Generated by AI");
10841082
});
10851083
});
1084+
1085+
describe("Table - Cell Merging", () => {
1086+
function mountMergedTable(overflowMode: "Scroll" | "Popin" = "Scroll") {
1087+
cy.mount(
1088+
<Table id="table" overflowMode={overflowMode}>
1089+
<TableSelectionMulti id="selection" slot="features"></TableSelectionMulti>
1090+
<TableHeaderRow slot="headerRow">
1091+
<TableHeaderCell id="colA" minWidth="200px">Column A</TableHeaderCell>
1092+
<TableHeaderCell id="colB" minWidth="200px">Column B</TableHeaderCell>
1093+
<TableHeaderCell id="colC" minWidth="150px">Column C</TableHeaderCell>
1094+
</TableHeaderRow>
1095+
<TableRow id="row1">
1096+
<TableCell id="r1cA"><Label>SAP</Label></TableCell>
1097+
<TableCell id="r1cB"><Label>100</Label></TableCell>
1098+
<TableCell id="r1cC"><Label>X</Label></TableCell>
1099+
</TableRow>
1100+
<TableRow id="row2">
1101+
<TableCell id="r2cA" merged><Label>SAP</Label></TableCell>
1102+
<TableCell id="r2cB"><Label>200</Label></TableCell>
1103+
<TableCell id="r2cC" merged><Label>X</Label></TableCell>
1104+
</TableRow>
1105+
<TableRow id="row3">
1106+
<TableCell id="r3cA" merged><Label>SAP</Label></TableCell>
1107+
<TableCell id="r3cB"><Label>300</Label></TableCell>
1108+
<TableCell id="r3cC"><Label>Y</Label></TableCell>
1109+
</TableRow>
1110+
</Table>
1111+
);
1112+
}
1113+
1114+
it("should have transparent border on merged cells and selection cell", () => {
1115+
mountMergedTable();
1116+
1117+
// Merged cell should have transparent top border
1118+
cy.get("#r2cA").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1119+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "0");
1120+
cy.get("#r2cA").find("ui5-label").should("have.css", "pointer-events", "none");
1121+
1122+
// Non-merged cell should not have transparent border
1123+
cy.get("#r2cB").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1124+
cy.get("#r2cB").find("ui5-label").should("have.css", "opacity", "1");
1125+
1126+
// Selection cell should have transparent border when first cell is merged
1127+
cy.get("#row2").shadow().find("#selection-cell").should("have.attr", "data-border-merged");
1128+
cy.get("#row2").shadow().find("#selection-cell").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1129+
1130+
// Selection cell should NOT have transparent border when first cell is not merged
1131+
cy.get("#row1").shadow().find("#selection-cell").should("not.have.attr", "data-border-merged");
1132+
cy.get("#row1").shadow().find("#selection-cell").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1133+
});
1134+
1135+
it("should disable merged styles when row has popin", () => {
1136+
mountMergedTable("Popin");
1137+
1138+
// At full width, merged styles should be active
1139+
cy.get("ui5-table").invoke("css", "width", "600px");
1140+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "0");
1141+
cy.get("#r2cA").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1142+
cy.get("#row2").shadow().find("#selection-cell").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1143+
1144+
// Shrink table to trigger popin
1145+
cy.get("ui5-table").invoke("css", "width", "250px");
1146+
cy.wait(50);
1147+
1148+
// Merged cell border should fall back to normal border color (not transparent)
1149+
cy.get("#row2").should("have.attr", "_haspopin");
1150+
cy.get("#r2cA").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1151+
cy.get("#row2").shadow().find("#selection-cell").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1152+
1153+
// Merged cell content should be fully visible (opacity back to 1)
1154+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "1");
1155+
cy.get("#r2cA").find("ui5-label").should("have.css", "pointer-events", "auto");
1156+
1157+
// Expand table again, merged styles should re-activate
1158+
cy.get("ui5-table").invoke("css", "width", "600px");
1159+
cy.wait(50);
1160+
1161+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "0");
1162+
cy.get("#r2cA").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1163+
cy.get("#row2").shadow().find("#selection-cell").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1164+
});
1165+
1166+
it("should toggle merged styles at runtime", () => {
1167+
mountMergedTable();
1168+
1169+
// Initially merged
1170+
cy.get("#r3cA").find("ui5-label").should("have.css", "opacity", "0");
1171+
1172+
// Remove merged attribute
1173+
cy.get("#r3cA").invoke("removeAttr", "merged");
1174+
cy.get("#r3cA").find("ui5-label").should("have.css", "opacity", "1");
1175+
cy.get("#r3cA").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1176+
1177+
// Re-add merged attribute
1178+
cy.get("#r3cA").invoke("prop", "merged", true);
1179+
cy.get("#r3cA").find("ui5-label").should("have.css", "opacity", "0");
1180+
cy.get("#r3cA").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1181+
});
1182+
1183+
it("should disable merged styles on hover and focus", () => {
1184+
mountMergedTable();
1185+
1186+
// Before hover: merged styles active
1187+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "0");
1188+
cy.get("#r2cA").should("have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1189+
1190+
// On hover: merged cell content should become visible and border should restore
1191+
cy.get("#row2").realHover();
1192+
cy.get("#r2cA").find("ui5-label").should("not.have.css", "opacity", "0");
1193+
cy.get("#r2cA").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1194+
cy.get("#row2").shadow().find("#selection-cell").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1195+
1196+
// On focus: merged cell content should become visible and border should restore
1197+
cy.get("#row3").realClick();
1198+
cy.get("#r3cA").find("ui5-label").should("not.have.css", "opacity", "0");
1199+
cy.get("#r3cA").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1200+
cy.get("#row3").shadow().find("#selection-cell").should("not.have.css", "border-top-color", "rgba(0, 0, 0, 0)");
1201+
});
1202+
});

packages/main/src/TableCell.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
2+
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
23
import query from "@ui5/webcomponents-base/dist/decorators/query.js";
34
import TableCellTemplate from "./TableCellTemplate.js";
45
import TableCellStyles from "./generated/themes/TableCell.css.js";
@@ -30,6 +31,20 @@ import { LABEL_COLON } from "./generated/i18n/i18n-defaults.js";
3031
template: TableCellTemplate,
3132
})
3233
class TableCell extends TableCellBase {
34+
/**
35+
* Defines whether the cell is visually merged with the cell directly above it.
36+
*
37+
* This is useful when consecutive cells in a column have the same value and should visually appear as a single merged cell.
38+
*
39+
* **Note:** This feature is disabled when cells are rendered as popin, and should remain `false` for interactive cell content.
40+
*
41+
* @default false
42+
* @since 2.21.0
43+
* @public
44+
*/
45+
@property({ type: Boolean })
46+
merged = false;
47+
3348
@query("#popin-header")
3449
_popinHeader?: HTMLElement;
3550

packages/main/src/TableHeaderRowTemplate.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ export default function TableHeaderRowTemplate(this: TableHeaderRow, ariaColInde
1616
aria-description={this._selectionCellAriaDescription}
1717
aria-colindex={ariaColIndex++}
1818
data-ui5-table-selection-cell
19-
data-ui5-table-cell-fixed
2019
data-ui5-acc-text=""
2120
>
2221
{ !this._isMultiSelect ?

packages/main/src/TableRow.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class TableRow extends TableRowBase<TableCell> {
4949
"default": true,
5050
individualSlots: true,
5151
invalidateOnChildChange: {
52-
properties: ["_popin", "_popinHidden"],
52+
properties: ["merged", "_popin", "_popinHidden"],
5353
slots: false,
5454
},
5555
})
@@ -130,6 +130,7 @@ class TableRow extends TableRowBase<TableCell> {
130130
toggleAttribute(this, "draggable", this.movable, "true");
131131
toggleAttribute(this, "_interactive", this._isInteractive);
132132
toggleAttribute(this, "_alternate", this._alternate);
133+
toggleAttribute(this, "_haspopin", this._hasPopin);
133134
}
134135

135136
async focus(focusOptions?: FocusOptions | undefined): Promise<void> {
@@ -197,6 +198,10 @@ class TableRow extends TableRowBase<TableCell> {
197198
}) !== undefined;
198199
}
199200

201+
get _hasPopin() {
202+
return this.cells.some(c => c._popin && !c._popinHidden);
203+
}
204+
200205
get _rowIndex() {
201206
if (this.position !== undefined) {
202207
return this.position;

packages/main/src/TableRowBase.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ abstract class TableRowBase<TCell extends TableCellBase = TableCellBase> extends
112112
return this.cells.filter(c => !c._popin);
113113
}
114114

115+
get _firstVisibleCell() {
116+
return this.cells.find(c => !c._popin);
117+
}
118+
115119
get _popinCells() {
116120
return this.cells.filter(c => c._popin && !c._popinHidden);
117121
}

packages/main/src/TableRowTemplate.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export default function TableRowTemplate(this: TableRow, ariaColIndex: number =
1313
<TableCell id="selection-cell"
1414
aria-selected={this._isSelected}
1515
aria-colindex={ariaColIndex++}
16+
data-border-merged={this._firstVisibleCell?.merged ? "" : null}
1617
data-ui5-table-selection-cell
17-
data-ui5-table-cell-fixed
1818
data-ui5-acc-text=""
1919
>
2020
{ this._isMultiSelect ?

packages/main/src/themes/TableCell.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
1+
:host {
2+
border-top: var(--sapList_BorderWidth) solid var(--sapList_BorderColor);
3+
}
4+
5+
:host([merged]),
6+
:host([data-border-merged]) {
7+
--_ui5_table_cell_merged_border_color: var(--_ui5_table_cell_merged) transparent;
8+
border-top-color: var(--_ui5_table_cell_merged_border_color, var(--sapList_BorderColor));
9+
}
10+
11+
:host([merged]) ::slotted(*) {
12+
interactivity: var(--_ui5_table_cell_merged) inert;
13+
pointer-events: var(--_ui5_table_cell_merged) none;
14+
opacity: var(--_ui5_table_cell_merged) 0;
15+
transition: opacity 300ms ease;
16+
}
17+
118
:host([_popin]) {
219
padding-inline-start: 0;
320
padding-inline-end: 0;
421
align-items: center;
22+
border-top: none;
523
}
624

725
:host([_popin]) #popin-header {

packages/main/src/themes/TableCellBase.css

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,4 @@
1818
:host([tabindex]:focus) {
1919
outline: var(--sapContent_FocusWidth) var(--sapContent_FocusStyle) var(--sapContent_FocusColor);
2020
outline-offset: calc(-1 * var(--sapContent_FocusWidth));
21-
}
22-
23-
:host(#selection-cell) {
24-
width: auto;
25-
min-width: auto;
26-
background-color: inherit;
27-
}
28-
29-
:host([data-ui5-table-cell-fixed]) {
30-
position: sticky;
31-
z-index: 1;
3221
}

packages/main/src/themes/TableHeaderRow.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
:host {
22
background: var(--sapList_HeaderBackground);
3-
border-top: var(--sapList_BorderWidth) solid var(--sapList_BorderColor);
3+
border-top: var(--sapList_BorderWidth) solid var(--sapList_HeaderBorderColor);
44
border-bottom: var(--sapList_BorderWidth) solid var(--sapList_HeaderBorderColor);
55
grid-template-rows: auto 0px;
66
}

packages/main/src/themes/TableRow.css

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,29 @@
66
background: var(--_ui5_table_row_alternating_background);
77
}
88

9-
:host([aria-selected=true]) {
9+
:host(:first-of-type) > [ui5-table-cell],
10+
:host(:first-of-type) > ::slotted([ui5-table-cell]) {
11+
border-top: none;
12+
}
13+
14+
:host(:last-of-type) {
15+
border-bottom: var(--sapList_BorderWidth) solid var(--sapList_TableFooterBorder);
16+
}
17+
18+
:host([aria-selected="true"]) {
1019
background: var(--sapList_SelectionBackgroundColor);
11-
border-bottom: var(--sapList_BorderWidth) solid var(--sapList_SelectionBorderColor);
20+
box-shadow: inset 0 calc(-1 * var(--sapList_BorderWidth)) 0 0 var(--sapList_SelectionBorderColor);
21+
}
22+
23+
:host(:not([_haspopin])) {
24+
/* Use CSS Space Toggles until if() or container style queries are widely supported */
25+
--_ui5_table_cell_merged: ;
26+
}
27+
28+
:host(:not([_haspopin]):hover),
29+
:host(:not([_haspopin]):focus-within) {
30+
/* Provide a valid CSS value to intentionally invalidate the TableCell variable-based rules and disable visual merging. */
31+
--_ui5_table_cell_merged: initial;
1232
}
1333

1434
@media (hover: hover) {
@@ -21,7 +41,7 @@
2141
}
2242

2343
:host([_interactive][_active]),
24-
:host([_interactive][aria-selected=true][_active]) {
44+
:host([_interactive][aria-selected="true"][_active]) {
2545
background: var(--sapList_Active_Background);
2646
}
2747

@@ -34,35 +54,34 @@
3454
}
3555

3656
#popin-cell {
57+
padding-inline-start: var(--_ui5_first_table_cell_horizontal_padding);
3758
align-content: initial;
3859
flex-direction: column;
3960
grid-column: 1 / -1;
61+
border-top: none;
4062
}
4163

4264
#navigated-cell {
43-
position: sticky;
44-
right: 0;
65+
position: sticky;
66+
inset-inline-end: 0;
67+
z-index: 1;
68+
background-color: inherit;
4569
overflow: visible;
4670
grid-row: span 2;
4771
min-width: 0;
4872
padding: 0;
49-
background: inherit;
50-
}
51-
52-
:dir(rtl)#navigated-cell {
53-
left: 0;
5473
}
5574

5675
:host([navigated]) #navigated {
5776
position: absolute;
58-
inset: 0;
77+
inset: -1px 0px 0px 1px;
5978
background: var(--sapList_SelectionBorderColor);
6079
}
6180

6281
:host([tabindex]:focus) #navigated {
6382
transform: translateX(calc(var(--_ui5_table_navigated_cell_width) * -1));
64-
bottom: 2px;
65-
top: 3px;
83+
bottom: 3px;
84+
top: 2px;
6685
}
6786

6887
:host([tabindex]:focus) #navigated:dir(rtl) {
@@ -83,19 +102,13 @@
83102

84103
#selection-cell ~ #popin-cell {
85104
grid-column-start: 2;
105+
padding-inline-start: var(--_ui5_table_cell_horizontal_padding);
86106
}
87107

88108
#actions-cell {
89-
display: flex;
90-
align-items: center;
91109
gap: var(--_ui5_table_row_actions_gap);
92110
}
93111

94112
#actions-cell:has(+ #navigated-cell) {
95-
right: var(--_ui5_table_navigated_cell_width);
96-
overflow: auto;
97-
}
98-
99-
:dir(rtl)#actions-cell:has(+ #navigated-cell) {
100-
left: var(--_ui5_table_navigated_cell_width);
113+
inset-inline-end: var(--_ui5_table_navigated_cell_width);
101114
}

0 commit comments

Comments
 (0)