Skip to content

Commit

Permalink
feat(ui5-table): add range selection to selection feature (#9205)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
DonkeyCo authored Jun 24, 2024
1 parent 165d7bc commit 55d99d2
Show file tree
Hide file tree
Showing 13 changed files with 581 additions and 29 deletions.
2 changes: 1 addition & 1 deletion packages/main/src/Table.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<div id="after" role="none" tabindex="0" ui5-table-dummy-focus-area></div>

{{#*inline "growingRow"}}
<ui5-table-row id="growing-row">
<ui5-table-row id="growing-row" ui5-growing-row>
<ui5-table-cell id="growing-cell">
<!-- The growing button is a div filling the cell -->
<!-- It has a growing text at the top and a growingSubText at the bottom -->
Expand Down
4 changes: 4 additions & 0 deletions packages/main/src/TableCellBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions packages/main/src/TableHeaderCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/main/src/TableHeaderRow.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
<ui5-table-header-cell id="selection-cell"
aria-selected="{{_isSelected}}"
aria-label="{{_i18nSelection}}"
ui5-table-selection-component
>
{{#if _isMultiSelect}}
<ui5-checkbox id="selection-component" tabindex="-1"
?checked="{{_isSelected}}"
@ui5-change="{{_informSelectionChange}}"
accessible-name="{{_i18nRowSelector}}"
@ui5-change="{{_informSelectionChange}}"
></ui5-checkbox>
{{/if}}
</ui5-table-header-cell>
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/TableHeaderRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class TableHeaderRow extends TableRowBase {
}
}

isHeaderRow() {
isHeaderRow(): boolean {
return true;
}

Expand Down
11 changes: 6 additions & 5 deletions packages/main/src/TableNavigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
isUp,
isUpShift,
isDown,
isDownShift,
isLeft,
isRight,
isPageUp,
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions packages/main/src/TableRow.hbs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{{#if _hasRowSelector}}
<ui5-table-cell id="selection-cell" aria-selected="{{_isSelected}}">
<ui5-table-cell id="selection-cell" aria-selected="{{_isSelected}}" ui5-table-selection-component>
{{#if _isMultiSelect}}
<ui5-checkbox id="selection-component" tabindex="-1"
?checked="{{_isSelected}}"
@ui5-change="{{_informSelectionChange}}"
accessible-name="{{_i18nRowSelector}}"
@ui5-change="{{_informSelectionChange}}"
></ui5-checkbox>
{{else}}
<ui5-radio-button id="selection-component" tabindex="-1"
Expand Down
11 changes: 6 additions & 5 deletions packages/main/src/TableRowBase.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import { isEnter, isSpace } from "@ui5/webcomponents-base/dist/Keys.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js";
import { isEnter, isSpace } from "@ui5/webcomponents-base/dist/Keys.js";
import type TableCellBase from "./TableCellBase.js";
import TableRowBaseCss from "./generated/themes/TableRowBase.css.js";
import type Table from "./Table.js";
Expand Down Expand Up @@ -41,6 +41,7 @@ abstract class TableRowBase extends UI5Element {

onEnterDOM() {
this.setAttribute("role", "row");
this.toggleAttribute("ui5-table-row-base", true);
}

onBeforeRendering() {
Expand All @@ -55,14 +56,14 @@ abstract class TableRowBase extends UI5Element {
return this;
}

isHeaderRow() {
return false;
}

_informSelectionChange() {
this._tableSelection?.informSelectionChange(this);
}

isHeaderRow(): boolean {
return false;
}

_onkeydown(e: KeyboardEvent, eventOrigin: HTMLElement) {
if ((eventOrigin === this && this._isSelectable && isSpace(e)) || (eventOrigin === this._selectionCell && (isSpace(e) || isEnter(e)))) {
this._informSelectionChange();
Expand Down
179 changes: 174 additions & 5 deletions packages/main/src/TableSelection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
isUpShift,
isShift,
} from "@ui5/webcomponents-base/dist/Keys.js";
import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
Expand All @@ -7,6 +12,7 @@ import type Table from "./Table.js";
import type { ITableFeature } from "./Table.js";
import type TableRow from "./TableRow.js";
import type TableRowBase from "./TableRowBase.js";
import { isSelectionCheckbox, isHeaderSelector, findRowInPath } from "./TableUtils.js";

/**
* @class
Expand Down Expand Up @@ -72,6 +78,7 @@ class TableSelection extends UI5Element implements ITableFeature {
selected = "";

_table?: Table;
_rangeSelection?: {selected: boolean, isUp: boolean | null, rows: TableRow[], isMouse: boolean, shiftPressed: boolean} | null;

onTableActivate(table: Table) {
this._table = table;
Expand Down Expand Up @@ -142,6 +149,10 @@ class TableSelection extends UI5Element implements ITableFeature {
}

informSelectionChange(row: TableRowBase) {
if (this._rangeSelection?.isMouse && this._rangeSelection.shiftPressed) {
return;
}

if (row.isHeaderRow()) {
this._informHeaderRowSelectionChange();
} else {
Expand All @@ -165,16 +176,20 @@ class TableSelection extends UI5Element implements ITableFeature {
this.selectedAsArray = [...selectedSet];
}

_informRowSelectionChange(row: TableRow) {
const isRowSelected = this.isMultiSelect() ? this.isSelected(row) : true;
_selectRow(row: TableRow, selected: boolean) {
const rowIdentifier = this.getRowIdentifier(row);
if (this.selected && this.mode === TableSelectionMode.Multiple) {
if (this.mode === TableSelectionMode.Multiple) {
const selectedSet = this.selectedAsSet;
selectedSet[isRowSelected ? "delete" : "add"](rowIdentifier);
selectedSet[selected ? "add" : "delete"](rowIdentifier);
this.selectedAsSet = selectedSet;
} else {
this.selected = rowIdentifier;
this.selected = selected ? rowIdentifier : "";
}
}

_informRowSelectionChange(row: TableRow) {
const isRowSelected = this.isMultiSelect() ? !this.isSelected(row) : true;
this._selectRow(row, isRowSelected);
this.fireEvent("change");
}

Expand Down Expand Up @@ -204,6 +219,160 @@ class TableSelection extends UI5Element implements ITableFeature {
this._table.headerRow[0]._invalidate++;
this._table.rows.forEach(row => 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();
Expand Down
Loading

0 comments on commit 55d99d2

Please sign in to comment.