diff --git a/packages/main/src/Table.hbs b/packages/main/src/Table.hbs
index b5c082718fc2..554bb45e9539 100644
--- a/packages/main/src/Table.hbs
+++ b/packages/main/src/Table.hbs
@@ -35,7 +35,7 @@
{{#*inline "growingRow"}}
-
+
diff --git a/packages/main/src/TableCellBase.ts b/packages/main/src/TableCellBase.ts
index 25ee400bfa7b..276c7c7476db 100644
--- a/packages/main/src/TableCellBase.ts
+++ b/packages/main/src/TableCellBase.ts
@@ -38,6 +38,10 @@ abstract class TableCellBase extends UI5Element {
TableCellBase.i18nBundle = await getI18nBundle("@ui5/webcomponents");
}
+ onEnterDOM() {
+ this.toggleAttribute("ui5-table-cell-base", true);
+ }
+
onBeforeRendering() {
if (this._popin) {
this.removeAttribute("role");
diff --git a/packages/main/src/TableHeaderCell.ts b/packages/main/src/TableHeaderCell.ts
index 1b9c90987262..a4193f61d620 100644
--- a/packages/main/src/TableHeaderCell.ts
+++ b/packages/main/src/TableHeaderCell.ts
@@ -79,6 +79,7 @@ class TableHeaderCell extends TableCellBase {
_popinWidth: number = 0;
onEnterDOM() {
+ super.onEnterDOM();
this.style.minWidth = this.minWidth;
this.style.maxWidth = this.maxWidth;
this.style.width = this.width;
diff --git a/packages/main/src/TableHeaderRow.hbs b/packages/main/src/TableHeaderRow.hbs
index 4f155d401e40..3f7e566045a9 100644
--- a/packages/main/src/TableHeaderRow.hbs
+++ b/packages/main/src/TableHeaderRow.hbs
@@ -2,12 +2,13 @@
{{#if _isMultiSelect}}
{{/if}}
diff --git a/packages/main/src/TableHeaderRow.ts b/packages/main/src/TableHeaderRow.ts
index 73d645420354..f663ef888dbf 100644
--- a/packages/main/src/TableHeaderRow.ts
+++ b/packages/main/src/TableHeaderRow.ts
@@ -78,7 +78,7 @@ class TableHeaderRow extends TableRowBase {
}
}
- isHeaderRow() {
+ isHeaderRow(): boolean {
return true;
}
diff --git a/packages/main/src/TableNavigation.ts b/packages/main/src/TableNavigation.ts
index d731478ddf2b..afda84d60e37 100644
--- a/packages/main/src/TableNavigation.ts
+++ b/packages/main/src/TableNavigation.ts
@@ -1,6 +1,8 @@
import {
isUp,
+ isUpShift,
isDown,
+ isDownShift,
isLeft,
isRight,
isPageUp,
@@ -18,7 +20,6 @@ import type Table from "./Table.js";
import type TableRowBase from "./TableRowBase.js";
import TableExtension from "./TableExtension.js";
import GridWalker from "./GridWalker.js";
-import { isInstanceOfTableCellBase, isInstanceOfTableRowBase } from "./TableUtils.js";
/**
* Handles the keyboard navigation for the ui5-table.
@@ -126,7 +127,7 @@ class TableNavigation extends TableExtension {
}
_handleEnter(e: KeyboardEvent, eventOrigin: HTMLElement) {
- if (isInstanceOfTableCellBase(eventOrigin)) {
+ if (eventOrigin.hasAttribute("ui5-table-cell-base")) {
this._handleF2(e, eventOrigin);
}
}
@@ -142,7 +143,7 @@ class TableNavigation extends TableExtension {
}
_handleF7(e: KeyboardEvent, eventOrigin: HTMLElement) {
- if (isInstanceOfTableRowBase(eventOrigin)) {
+ if (eventOrigin.hasAttribute("ui5-table-row-base")) {
this._gridWalker.setColPos(this._colPosition);
let elementToFocus = this._gridWalker.getCurrent() as HTMLElement;
if (this._tabPosition > -1) {
@@ -223,9 +224,9 @@ class TableNavigation extends TableExtension {
this._gridWalker[this._table.effectiveDir === "rtl" ? "right" : "left"]();
} else if (isRight(e)) {
this._gridWalker[this._table.effectiveDir === "rtl" ? "left" : "right"]();
- } else if (isUp(e)) {
+ } else if (isUp(e) || isUpShift(e)) {
this._gridWalker.up();
- } else if (isDown(e)) {
+ } else if (isDown(e) || isDownShift(e)) {
this._gridWalker.down();
} else if (isHome(e)) {
this._gridWalker.home();
diff --git a/packages/main/src/TableRow.hbs b/packages/main/src/TableRow.hbs
index c591c5f50e03..00e559424f79 100644
--- a/packages/main/src/TableRow.hbs
+++ b/packages/main/src/TableRow.hbs
@@ -1,10 +1,10 @@
{{#if _hasRowSelector}}
-
+
{{#if _isMultiSelect}}
{{else}}
row._invalidate++);
}
+
+ _onkeydown(e: KeyboardEvent) {
+ if (!this.isMultiSelect() || !this._table || !e.shiftKey) {
+ return;
+ }
+
+ const focusedElement = getActiveElement(); // Assumption: The focused element is always the "next" row after navigation.
+
+ if (!(focusedElement?.hasAttribute("ui5-table-row") || this._rangeSelection?.isMouse || focusedElement?.hasAttribute("ui5-growing-row"))) {
+ this._stopRangeSelection();
+ return;
+ }
+
+ if (!this._rangeSelection) {
+ // If no range selection is active, start one
+ this._startRangeSelection(focusedElement as TableRow);
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+ const change = isUpShift(e) ? -1 : 1;
+ this._handleRangeSelection(focusedElement as TableRow, change);
+ }
+
+ if (this._rangeSelection) {
+ this._rangeSelection.shiftPressed = e.shiftKey;
+ }
+ }
+
+ _onkeyup(e: KeyboardEvent, eventOrigin: HTMLElement) {
+ if (!this._table) {
+ return;
+ }
+
+ if (!eventOrigin.hasAttribute("ui5-table-row") || !this._rangeSelection || isShift(e) || !isSelectionCheckbox(e)) {
+ // Stop range selection if a) Shift is relased or b) the event target is not a row or c) the event is not from the selection checkbox
+ this._stopRangeSelection();
+ }
+
+ if (this._rangeSelection) {
+ this._rangeSelection.shiftPressed = e.shiftKey;
+ }
+ }
+
+ _onclick(e: MouseEvent) {
+ if (!this._table) {
+ return;
+ }
+
+ if (isHeaderSelector(e)) {
+ this._stopRangeSelection();
+ return;
+ }
+
+ if (!isSelectionCheckbox(e)) {
+ this._stopRangeSelection();
+ return;
+ }
+
+ const row = findRowInPath(e.composedPath());
+
+ if (e.shiftKey && this._rangeSelection?.isMouse) {
+ const startRow = this._rangeSelection.rows[0];
+ const startIndex = this._table.rows.indexOf(startRow);
+ const endIndex = this._table.rows.indexOf(row);
+
+ // When doing a range selection and clicking on an already selected row, the checked status should not change
+ // Therefore, we need to manually set the checked attribute again, as clicking it would deselect it and leads to
+ // a visual inconsistency.
+ row.shadowRoot?.querySelector("#selection-component")?.toggleAttribute("checked", true);
+
+ if (startIndex === -1 || endIndex === -1 || row.key === startRow.key || row.key === this._rangeSelection.rows[this._rangeSelection.rows.length - 1].key) {
+ return;
+ }
+
+ const change = endIndex - startIndex;
+ this._handleRangeSelection(row, change);
+ } else if (row) {
+ this._startRangeSelection(row, true);
+ }
+ }
+
+ /**
+ * Start the range selection and initialises the range selection state
+ * @param row starting row
+ * @private
+ */
+ _startRangeSelection(row: TableRow, isMouse = false) {
+ const selected = this.isSelected(row);
+ if (isMouse && !selected) {
+ // Do not initiate range selection if the row is not selected
+ return;
+ }
+
+ this._rangeSelection = {
+ selected,
+ isUp: null,
+ rows: [row],
+ isMouse,
+ shiftPressed: false,
+ };
+ }
+
+ /**
+ * Handles the range selection
+ * @param targetRow row that is currently focused
+ * @param change indicates direction
+ * @private
+ */
+ _handleRangeSelection(targetRow: TableRow, change: number) {
+ if (!this._rangeSelection) {
+ return;
+ }
+
+ const isUp = change > 0;
+ this._rangeSelection.isUp ??= isUp;
+
+ const shouldReverseSelection = isUp !== this._rangeSelection.isUp && !this._rangeSelection.isMouse;
+ let selectionChanged = shouldReverseSelection && this.isSelected(targetRow);
+
+ if (shouldReverseSelection) {
+ this._reverseRangeSelection();
+ } else {
+ const rowIndex = this._table!.rows.indexOf(targetRow);
+ const [startIndex, endIndex] = [rowIndex, rowIndex - change].sort((a, b) => a - b);
+
+ selectionChanged = this._table?.rows.slice(startIndex, endIndex + 1).reduce((changed, row) => {
+ const isRowNotInSelection = !this._rangeSelection?.rows.includes(row);
+ const isRowSelectionDifferent = this.isSelected(row) !== this._rangeSelection!.selected;
+
+ if (isRowNotInSelection) {
+ this._rangeSelection?.rows.push(row);
+ }
+
+ this._selectRow(row, this._rangeSelection!.selected);
+
+ return changed || isRowSelectionDifferent;
+ }, selectionChanged) || false;
+ }
+
+ selectionChanged && this._fireEvent("change");
+ }
+
+ _stopRangeSelection() {
+ this._rangeSelection = null;
+ }
+
+ _reverseRangeSelection() {
+ const row = this._rangeSelection?.rows.pop();
+ if (row) {
+ this._selectRow(row, false);
+ }
+
+ if (this._rangeSelection?.rows.length === 1) {
+ this._rangeSelection.isUp = null;
+ }
+ }
}
TableSelection.define();
diff --git a/packages/main/src/TableUtils.ts b/packages/main/src/TableUtils.ts
index b040735a9b1d..c9e19c0e16e6 100644
--- a/packages/main/src/TableUtils.ts
+++ b/packages/main/src/TableUtils.ts
@@ -1,21 +1,25 @@
import type Table from "./Table";
-import type TableCellBase from "./TableCellBase";
-import type TableRowBase from "./TableRowBase";
+import type TableRow from "./TableRow";
const isInstanceOfTable = (obj: any): obj is Table => {
return "isTable" in obj && !!obj.isTable;
};
-const isInstanceOfTableCellBase = (obj: any): obj is TableCellBase => {
- return "isTableCellBase" in obj && !!obj.isTableCellBase;
+const isSelectionCheckbox = (e: Event) => {
+ return e.composedPath().some((el: EventTarget) => (el as HTMLElement).hasAttribute?.("ui5-table-selection-component"));
};
-const isInstanceOfTableRowBase = (obj: any): obj is TableRowBase => {
- return "isTableRowBase" in obj && !!obj.isTableRowBase;
+const isHeaderSelector = (e: Event) => {
+ return isSelectionCheckbox(e) && e.composedPath().some((el: EventTarget) => el instanceof HTMLElement && el.hasAttribute("ui5-table-header-row"));
+};
+
+const findRowInPath = (composedPath: Array) => {
+ return composedPath.find((el: EventTarget) => el instanceof HTMLElement && el.hasAttribute("ui5-table-row")) as TableRow;
};
export {
isInstanceOfTable,
- isInstanceOfTableCellBase,
- isInstanceOfTableRowBase,
+ isSelectionCheckbox,
+ isHeaderSelector,
+ findRowInPath,
};
diff --git a/packages/main/test/pages/Table.html b/packages/main/test/pages/Table.html
index 5ecb4cd44c1f..7161e69e3d2f 100644
--- a/packages/main/test/pages/Table.html
+++ b/packages/main/test/pages/Table.html
@@ -25,7 +25,7 @@
-
+
diff --git a/packages/main/test/pages/TableSelection.html b/packages/main/test/pages/TableSelection.html
new file mode 100644
index 000000000000..118ed559ec53
--- /dev/null
+++ b/packages/main/test/pages/TableSelection.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+ Test Page - Table Selection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ColumnA
+ Column B
+ Column C
+ Column D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+ Cell A
+ Cell B
+ Cell C
+ Cell D
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/main/test/specs/TableSelection.spec.js b/packages/main/test/specs/TableSelection.spec.js
new file mode 100644
index 000000000000..fa2b2d4cf142
--- /dev/null
+++ b/packages/main/test/specs/TableSelection.spec.js
@@ -0,0 +1,271 @@
+import { assert } from "chai";
+
+const Keys = {
+ SHIFT: '\uE008',
+}
+
+async function changeMode(mode) {
+ await browser.execute((mode) => {
+ const selection = document.getElementById("selection");
+ selection.mode = mode;
+ }, mode);
+}
+
+async function clearSelection() {
+ await browser.execute(() => {
+ const selection = document.getElementById("selection");
+ selection.selected = "";
+ });
+}
+
+describe("Mode - None", async () => {
+ before(async () => {
+ await browser.url("test/pages/TableSelection.html");
+ await changeMode("None");
+ });
+
+ it("selection should be not active", async () => {
+ const table = await browser.$("#table0");
+ const headerRow = await table.$("ui5-table-header-row");
+ const row = await table.$('ui5-table-row[key="0"]');
+ const selection = await browser.$("#selection");
+
+ assert.ok(await table.isExisting(), "Table exists");
+ assert.ok(await headerRow.isExisting(), "Header row exists");
+ assert.ok(await row.isExisting(), "Row exists");
+
+ assert.equal(await selection.getProperty("mode"), "None", "Selection mode is none");
+ assert.notOk(await headerRow.shadow$("#selection-cell").isExisting(), "Selection checkbox does not exist");
+ assert.notOk(await row.shadow$("#selection-cell").isExisting(), "Selection checkbox does not exist");
+ });
+})
+
+const testConfig = {
+ "Single": {
+ "config": {
+ "mode": "Single",
+ },
+ "cases": {
+ "BOXES": {
+ "header": {
+ "exists": true,
+ "checkbox": false
+ },
+ "row": {
+ "exists": true,
+ "checkbox": true
+ }
+ },
+ "SPACE": {
+ "space_0": "0",
+ "space_4": "4"
+ },
+ "ARROWS_BOX": {
+ "arrow_initial": "0",
+ "arrow_down": "1",
+ "arrow_up": "0"
+ },
+ "MOUSE": {
+ "mouse_0": "0",
+ "mouse_4": "4"
+ },
+ "RANGE_MOUSE": {
+ "range_mouse_initial": "0",
+ "range_mouse_final": "4",
+ "range_mouse_edge": "0"
+ },
+ "RANGE_KEYBOARD": {
+ "initial": "0",
+ "block_1": "0",
+ "block_2": "6"
+ }
+ }
+ },
+ "Multiple": {
+ "config": {
+ "mode": "Multiple",
+ },
+ "cases": {
+ "BOXES": {
+ "header": {
+ "exists": true,
+ "checkbox": true
+ },
+ "row": {
+ "exists": true,
+ "checkbox": true
+ }
+ },
+ "SPACE": {
+ "space_0": "0",
+ "space_4": "0 4"
+ },
+ "ARROWS_BOX": {
+ "arrow_initial": "0",
+ "arrow_down": "0",
+ "arrow_up": "0"
+ },
+ "MOUSE": {
+ "mouse_0": "0",
+ "mouse_4": "0 4"
+ },
+ "RANGE_MOUSE": {
+ "range_mouse_initial": "0",
+ "range_mouse_final": "0 1 2 3 4",
+ "range_mouse_edge": "0 1 2 3 4"
+ },
+ "RANGE_KEYBOARD": {
+ "initial": "0",
+ "block_1": "0 1 2 3 4",
+ "block_2": "0 1 2 3 4 6 7 8 9"
+ }
+ }
+ }
+};
+
+Object.entries(testConfig).forEach(([mode, testConfig]) => {
+ describe(`Mode - ${mode}`, async () => {
+ before(async () => {
+ await browser.url("test/pages/TableSelection.html");
+ await changeMode(testConfig.config.mode);
+ });
+
+ beforeEach(async () => {
+ await clearSelection();
+ });
+
+ it("Correct boxes are shown", async () => {
+ const table = await browser.$("#table0");
+ const headerRow = await table.$("ui5-table-header-row");
+ const row = await table.$('ui5-table-row[key="0"]');
+
+ assert.ok(await table.isExisting(), "Table exists");
+ assert.ok(await headerRow.isExisting(), "Header row exists");
+ assert.ok(await row.isExisting(), "Row exists");
+
+ assert.equal(await headerRow.shadow$("#selection-cell").isExisting(), testConfig.cases.BOXES.header.exists, "Header row selection cell is rendered");
+ assert.equal(await row.shadow$("#selection-cell").isExisting(), testConfig.cases.BOXES.row.exists, "Row selection cell is rendered");
+
+ assert.equal(await headerRow.shadow$("#selection-component").isExisting(), testConfig.cases.BOXES.header.checkbox, "Header row checkbox is rendered");
+ assert.equal(await row.shadow$("#selection-component").isExisting(), testConfig.cases.BOXES.row.checkbox, "Row checkbox is rendered");
+ });
+
+ it("select row via SPACE", async () => {
+ const table = await browser.$("#table0");
+ const selection = await browser.$("#selection");
+ const row0 = await table.$('ui5-table-row[key="0"]');
+ const row4 = await table.$('ui5-table-row[key="4"]');
+
+ await row0.click();
+ await row0.keys("Space");
+
+ let selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.SPACE.space_0, `Rows with keys ${testConfig.cases.SPACE.space_0} selected`);
+
+ await row4.click();
+ await row4.keys("Space");
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.SPACE.space_4, `Rows with keys ${testConfig.cases.SPACE.space_4} selected`);
+ });
+
+ it("select row via arrows (radio focus)", async () => {
+ const table = await browser.$("#table0");
+ const selection = await browser.$("#selection");
+ const row0 = await table.$('ui5-table-row[key="0"]');
+
+ const checkbox0 = await row0.shadow$("#selection-component");
+ await checkbox0.click();
+
+ let selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.ARROWS_BOX.arrow_initial, `Rows with keys ${testConfig.cases.ARROWS_BOX.arrow_initial} selected`);
+
+ await browser.keys("ArrowDown");
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.ARROWS_BOX.arrow_down, `Rows with keys ${testConfig.cases.ARROWS_BOX.arrow_down} selected`);
+
+ await browser.keys("ArrowUp");
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.ARROWS_BOX.arrow_up, `Rows with keys ${testConfig.cases.ARROWS_BOX.arrow_up} selected`);
+ });
+
+ it("select row via mouse", async () => {
+ const table = await browser.$("#table0");
+ const selection = await browser.$("#selection");
+ const row0 = await table.$('ui5-table-row[key="0"]');
+ const row4 = await table.$('ui5-table-row[key="4"]');
+
+ const checkbox0 = await row0.shadow$("#selection-component");
+ await checkbox0.click();
+
+ let selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.MOUSE.mouse_0, `Rows with keys ${testConfig.cases.MOUSE.mouse_0} selected`);
+
+ const checkbox4 = await row4.shadow$("#selection-component");
+ await checkbox4.click();
+
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.MOUSE.mouse_4, `Rows with keys ${testConfig.cases.MOUSE.mouse_4} selected`);
+ });
+
+ it("range selection with mouse", async () => {
+ const table = await browser.$("#table0");
+ const selection = await browser.$("#selection");
+ const row0 = await table.$('ui5-table-row[key="0"]');
+ const row4 = await table.$('ui5-table-row[key="4"]');
+
+ const checkbox0 = await row0.shadow$("#selection-component");
+ const checkbox4 = await row4.shadow$("#selection-component");
+ await checkbox0.click();
+
+ let selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.RANGE_MOUSE.range_mouse_initial, `Rows with keys ${testConfig.cases.RANGE_MOUSE.range_mouse_initial} selected`);
+
+ await browser.performActions([{
+ type: "key",
+ id: "keyboard1",
+ actions: [{ type: "keyDown", value: Keys.SHIFT }],
+ }]);
+ await checkbox4.click();
+
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.RANGE_MOUSE.range_mouse_final, `Rows with keys ${testConfig.cases.RANGE_MOUSE.range_mouse_final} selected`);
+
+ await browser.performActions([{
+ type: "key",
+ id: "keyboard2",
+ actions: [{ type: "keyDown", value: Keys.SHIFT }],
+ }]);
+ await checkbox0.click();
+
+ await browser.releaseActions();
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.RANGE_MOUSE.range_mouse_edge, `Rows with keys ${testConfig.cases.RANGE_MOUSE.range_mouse_edge} selected`);
+ });
+
+ it("range selection with keyboard", async () => {
+ const table = await browser.$("#table0");
+ const selection = await browser.$("#selection");
+ const row0 = await table.$('ui5-table-row[key="0"]');
+
+ await row0.click();
+ await row0.keys("Space");
+
+ let selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.RANGE_KEYBOARD.initial, `Rows with keys ${testConfig.cases.RANGE_MOUSE.initial} selected`);
+
+ await browser.keys(["Shift", "ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown", "ArrowUp"]);
+
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.RANGE_KEYBOARD.block_1, `Rows with keys ${testConfig.cases.RANGE_KEYBOARD.block_1} selected`);
+
+ await browser.keys("ArrowDown");
+ await browser.keys("ArrowDown");
+
+ await browser.keys("Space");
+ await browser.keys(["Shift", "ArrowDown", "ArrowDown", "ArrowDown"]);
+
+ selected = await selection.getProperty("selected");
+ assert.equal(selected, testConfig.cases.RANGE_KEYBOARD.block_2, `Rows with keys ${testConfig.cases.RANGE_KEYBOARD.block_2} selected`);
+ });
+ });
+});
\ No newline at end of file