Skip to content

Commit c5ec5f3

Browse files
authored
feat(ui5-table-cell): merged property added (#13297)
* 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 * 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 syncs with the first merged cell - New TableCell website sample added for merged cells - Partially fixes #7238 - CPOUIFTEAMB-2624 * 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 syncs with the first merged cell - New TableCell website sample added for merged cells - Partially fixes #7238 - CPOUIFTEAMB-2624
1 parent 31e5f0a commit c5ec5f3

17 files changed

Lines changed: 725 additions & 77 deletions

File tree

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

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ 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";
16+
const TRANSPARENT = "rgba(0, 0, 0, 0)";
1817

1918
describe("Table - Rendering", () => {
2019
function checkWidth(id: string, expectedWidth: number) {
@@ -302,7 +301,7 @@ describe("Table - Popin Mode", () => {
302301
const roleCondition = shouldBePoppedIn || shouldBeHidden ? "not.have.attr" : "have.attr";
303302

304303
cy.wrap($cell)
305-
.should(roleCondition, "role", ROLE_COLUMN_HEADER);
304+
.should(roleCondition, "role", "columnheader");
306305
cy.get("ui5-table-header-row")
307306
.shadow()
308307
.find(`slot[name=default-${index + 1}]`)
@@ -1083,3 +1082,114 @@ describe("Table - HeaderCell", () => {
10831082
cy.get("@actionBclickTarget").should("have.attr", "tooltip", "Generated by AI");
10841083
});
10851084
});
1085+
1086+
describe("Table - Cell Merging", () => {
1087+
function mountMergedTable(overflowMode: "Scroll" | "Popin" = "Scroll") {
1088+
cy.mount(
1089+
<Table id="table" overflowMode={overflowMode}>
1090+
<TableSelectionMulti id="selection" slot="features"></TableSelectionMulti>
1091+
<TableHeaderRow slot="headerRow">
1092+
<TableHeaderCell id="colA" minWidth="200px">Column A</TableHeaderCell>
1093+
<TableHeaderCell id="colB" minWidth="200px">Column B</TableHeaderCell>
1094+
<TableHeaderCell id="colC" minWidth="150px">Column C</TableHeaderCell>
1095+
</TableHeaderRow>
1096+
<TableRow id="row1">
1097+
<TableCell id="r1cA"><Label>SAP</Label></TableCell>
1098+
<TableCell id="r1cB"><Label>100</Label></TableCell>
1099+
<TableCell id="r1cC"><Label>X</Label></TableCell>
1100+
</TableRow>
1101+
<TableRow id="row2">
1102+
<TableCell id="r2cA" merged><Label>SAP</Label></TableCell>
1103+
<TableCell id="r2cB"><Label>200</Label></TableCell>
1104+
<TableCell id="r2cC" merged><Label>X</Label></TableCell>
1105+
</TableRow>
1106+
<TableRow id="row3">
1107+
<TableCell id="r3cA" merged><Label>SAP</Label></TableCell>
1108+
<TableCell id="r3cB"><Label>300</Label></TableCell>
1109+
<TableCell id="r3cC"><Label>Y</Label></TableCell>
1110+
</TableRow>
1111+
</Table>
1112+
);
1113+
}
1114+
1115+
it("should have transparent border on merged cells and selection cell", () => {
1116+
mountMergedTable();
1117+
1118+
// Merged cell should have transparent top border
1119+
cy.get("#r2cA").should("have.css", "border-top-color", TRANSPARENT);
1120+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "0");
1121+
1122+
// Non-merged cell should not have transparent border
1123+
cy.get("#r2cB").should("not.have.css", "border-top-color", TRANSPARENT);
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", TRANSPARENT);
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", TRANSPARENT);
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", TRANSPARENT);
1142+
cy.get("#row2").shadow().find("#selection-cell").should("have.css", "border-top-color", TRANSPARENT);
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", TRANSPARENT);
1151+
cy.get("#row2").shadow().find("#selection-cell").should("not.have.css", "border-top-color", TRANSPARENT);
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+
1156+
// Expand table again, merged styles should re-activate
1157+
cy.get("ui5-table").invoke("css", "width", "600px");
1158+
cy.wait(50);
1159+
1160+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "0");
1161+
cy.get("#r2cA").should("have.css", "border-top-color", TRANSPARENT);
1162+
cy.get("#row2").shadow().find("#selection-cell").should("have.css", "border-top-color", TRANSPARENT);
1163+
});
1164+
1165+
it("should toggle merged styles at runtime", () => {
1166+
mountMergedTable();
1167+
1168+
// Initially merged
1169+
cy.get("#r3cA").find("ui5-label").should("have.css", "opacity", "0");
1170+
1171+
// Remove merged attribute
1172+
cy.get("#r3cA").invoke("removeAttr", "merged");
1173+
cy.get("#r3cA").find("ui5-label").should("have.css", "opacity", "1");
1174+
cy.get("#r3cA").should("not.have.css", "border-top-color", TRANSPARENT);
1175+
1176+
// Re-add merged attribute
1177+
cy.get("#r3cA").invoke("prop", "merged", true);
1178+
cy.get("#r3cA").find("ui5-label").should("have.css", "opacity", "0");
1179+
cy.get("#r3cA").should("have.css", "border-top-color", TRANSPARENT);
1180+
});
1181+
1182+
it("should disable merged styles on focus", () => {
1183+
mountMergedTable();
1184+
1185+
// Before hover: merged styles active
1186+
cy.get("#r2cA").find("ui5-label").should("have.css", "opacity", "0");
1187+
cy.get("#r2cA").should("have.css", "border-top-color", TRANSPARENT);
1188+
1189+
// On focus: merged cell content should become visible but border should remain transparent
1190+
cy.get("#row2").realClick();
1191+
cy.get("#r2cA").find("ui5-label").should("not.have.css", "opacity", "0");
1192+
cy.get("#r2cA").should("have.css", "border-top-color", TRANSPARENT);
1193+
cy.get("#row2").shadow().find("#selection-cell").should("have.css", "border-top-color", TRANSPARENT);
1194+
});
1195+
});

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+
* Although the cell is visually merged with the previous one, its content must still be provided for accessibility purposes.
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
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_border_merged) transparent;
8+
border-top-color: var(--_ui5_table_cell_merged_border_color, var(--sapList_BorderColor));
9+
}
10+
11+
:host([merged]) ::slotted(*) {
12+
--_ui5_table_cell_merged_content_opacity: var(--_ui5_table_cell_content_merged) 0;
13+
opacity: var(--_ui5_table_cell_merged_content_opacity, 1);
14+
transition: opacity 300ms ease;
15+
}
16+
117
:host([_popin]) {
218
padding-inline-start: 0;
319
padding-inline-end: 0;
420
align-items: center;
21+
border-top: none;
522
}
623

724
: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: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,30 @@
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_border_merged: ;
26+
--_ui5_table_cell_content_merged: ;
27+
}
28+
29+
:host(:not([_haspopin]):active),
30+
:host(:not([_haspopin]):focus-within) {
31+
/* Provide a valid CSS value to intentionally invalidate the TableCell variable-based rules and disable visual merging. */
32+
--_ui5_table_cell_content_merged: initial;
1233
}
1334

1435
@media (hover: hover) {
@@ -18,10 +39,14 @@
1839
:host([_interactive][aria-selected=true]:hover) {
1940
background: var(--sapList_Hover_SelectionBackground);
2041
}
42+
:host(:not([_haspopin]):hover) {
43+
/* Provide a valid CSS value to intentionally invalidate the TableCell variable-based rules and disable visual merging. */
44+
--_ui5_table_cell_content_merged: initial;
45+
}
2146
}
2247

2348
:host([_interactive][_active]),
24-
:host([_interactive][aria-selected=true][_active]) {
49+
:host([_interactive][aria-selected="true"][_active]) {
2550
background: var(--sapList_Active_Background);
2651
}
2752

@@ -34,35 +59,34 @@
3459
}
3560

3661
#popin-cell {
62+
padding-inline-start: var(--_ui5_first_table_cell_horizontal_padding);
3763
align-content: initial;
3864
flex-direction: column;
3965
grid-column: 1 / -1;
66+
border-top: none;
4067
}
4168

4269
#navigated-cell {
43-
position: sticky;
44-
right: 0;
70+
position: sticky;
71+
inset-inline-end: 0;
72+
z-index: 1;
73+
background-color: inherit;
4574
overflow: visible;
4675
grid-row: span 2;
4776
min-width: 0;
4877
padding: 0;
49-
background: inherit;
50-
}
51-
52-
:dir(rtl)#navigated-cell {
53-
left: 0;
5478
}
5579

5680
:host([navigated]) #navigated {
5781
position: absolute;
58-
inset: 0;
82+
inset: -1px 0px 0px 1px;
5983
background: var(--sapList_SelectionBorderColor);
6084
}
6185

6286
:host([tabindex]:focus) #navigated {
6387
transform: translateX(calc(var(--_ui5_table_navigated_cell_width) * -1));
64-
bottom: 2px;
65-
top: 3px;
88+
bottom: 3px;
89+
top: 2px;
6690
}
6791

6892
:host([tabindex]:focus) #navigated:dir(rtl) {
@@ -83,19 +107,13 @@
83107

84108
#selection-cell ~ #popin-cell {
85109
grid-column-start: 2;
110+
padding-inline-start: var(--_ui5_table_cell_horizontal_padding);
86111
}
87112

88113
#actions-cell {
89-
display: flex;
90-
align-items: center;
91114
gap: var(--_ui5_table_row_actions_gap);
92115
}
93116

94117
#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);
118+
inset-inline-end: var(--_ui5_table_navigated_cell_width);
101119
}

0 commit comments

Comments
 (0)