From 55d99d25324f64c202339c4947f2b3bcf35e25c8 Mon Sep 17 00:00:00 2001 From: Duc Vo Ngoc Date: Mon, 24 Jun 2024 15:32:02 +0200 Subject: [PATCH] feat(ui5-table): add range selection to selection feature (#9205) In "Multiple" selection mode, it is now possible to select ranges with a) mouse or b) keyboard. a) Select a row via checkbox and Shift + Click on another checkbox to select the range. b) Select a row and Shift + Up/Down to extend your range selection. --- packages/main/src/Table.hbs | 2 +- packages/main/src/TableCellBase.ts | 4 + packages/main/src/TableHeaderCell.ts | 1 + packages/main/src/TableHeaderRow.hbs | 3 +- packages/main/src/TableHeaderRow.ts | 2 +- packages/main/src/TableNavigation.ts | 11 +- packages/main/src/TableRow.hbs | 4 +- packages/main/src/TableRowBase.ts | 11 +- packages/main/src/TableSelection.ts | 179 +++++++++++- packages/main/src/TableUtils.ts | 20 +- packages/main/test/pages/Table.html | 2 +- packages/main/test/pages/TableSelection.html | 100 +++++++ .../main/test/specs/TableSelection.spec.js | 271 ++++++++++++++++++ 13 files changed, 581 insertions(+), 29 deletions(-) create mode 100644 packages/main/test/pages/TableSelection.html create mode 100644 packages/main/test/specs/TableSelection.spec.js 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