UNPKG

@blueprintjs/table

Version:

Scalable interactive table component

359 lines 18.2 kB
/* * Copyright 2021 Palantir Technologies, Inc. All rights reserved. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Children } from "react"; import { FocusMode } from "./common/cellTypes"; import { Clipboard } from "./common/clipboard"; import { Direction } from "./common/direction"; import { TABLE_COPY_FAILED } from "./common/errors"; import {} from "./common/grid"; import * as FocusedCellUtils from "./common/internal/focusedCellUtils"; import * as SelectionUtils from "./common/internal/selectionUtils"; import { RegionCardinality, Regions } from "./regions"; export class TableHotkeys { props; state; tableHandlers; grid; constructor(props, state, tableHandlers) { this.props = props; this.state = state; this.tableHandlers = tableHandlers; // no-op } setGrid(grid) { this.grid = grid; } setProps(props) { this.props = props; } setState(newState) { if (newState.focusedRegion != null && (this.state.focusedRegion == null || !FocusedCellUtils.areFocusedRegionsEqual(this.state.focusedRegion, newState.focusedRegion))) { this.scrollBodyToFocusedRegion(newState.focusedRegion); } this.state = newState; } // Selection // ========= selectAll = (shouldUpdateFocusedCell) => { const selectionHandler = this.tableHandlers.getEnabledSelectionHandler(RegionCardinality.FULL_TABLE); // clicking on upper left hand corner sets selection to "all" // regardless of current selection state (clicking twice does not deselect table) selectionHandler([Regions.table()]); if (shouldUpdateFocusedCell) { const focusMode = FocusedCellUtils.getFocusModeFromProps(this.props); const newFocusedCellCoordinates = Regions.getFocusCellCoordinatesFromRegion(Regions.table()); const newFocusedRegion = FocusedCellUtils.toFocusedRegion(focusMode, newFocusedCellCoordinates); if (newFocusedRegion != null) { this.tableHandlers.handleFocus(newFocusedRegion); } } }; handleSelectAllHotkey = (e) => { // prevent "real" select all from happening as well e.preventDefault(); e.stopPropagation(); // selecting-all via the keyboard should not move the focused cell. this.selectAll(false); }; handleSelectionResizeUp = (e) => this.handleSelectionResize(e, Direction.UP); handleSelectionResizeDown = (e) => this.handleSelectionResize(e, Direction.DOWN); handleSelectionResizeLeft = (e) => this.handleSelectionResize(e, Direction.LEFT); handleSelectionResizeRight = (e) => this.handleSelectionResize(e, Direction.RIGHT); handleSelectionResize = (e, direction) => { e.preventDefault(); e.stopPropagation(); const { focusedRegion, selectedRegions } = this.state; const index = FocusedCellUtils.getFocusedOrLastSelectedIndex(selectedRegions, focusedRegion); if (index === undefined) { return; } const region = selectedRegions[index]; const nextRegion = SelectionUtils.resizeRegion(region, direction, focusedRegion); this.updateSelectedRegionAtIndex(nextRegion, index); }; /** * Replaces the selected region at the specified array index, with the * region provided. */ updateSelectedRegionAtIndex(region, index) { const { children, numRows } = this.props; const { selectedRegions } = this.state; const numColumns = Children.count(children); const maxRowIndex = Math.max(0, numRows - 1); const maxColumnIndex = Math.max(0, numColumns - 1); const clampedNextRegion = Regions.clampRegion(region, maxRowIndex, maxColumnIndex); const nextSelectedRegions = Regions.update(selectedRegions, clampedNextRegion, index); this.tableHandlers.handleSelection(nextSelectedRegions); } // Focus // ===== handleFocusMoveLeft = (e) => this.handleFocusMove(e, Direction.LEFT); handleFocusMoveLeftInternal = (e) => this.handleFocusMoveInternal(e, Direction.LEFT); handleFocusMoveRight = (e) => this.handleFocusMove(e, Direction.RIGHT); handleFocusMoveRightInternal = (e) => this.handleFocusMoveInternal(e, Direction.RIGHT); handleFocusMoveUp = (e) => this.handleFocusMove(e, Direction.UP); handleFocusMoveUpInternal = (e) => this.handleFocusMoveInternal(e, Direction.UP); handleFocusMoveDown = (e) => this.handleFocusMove(e, Direction.DOWN); handleFocusMoveDownInternal = (e) => this.handleFocusMoveInternal(e, Direction.DOWN); // no good way to call arrow-key keyboard events from tests /* istanbul ignore next */ handleFocusMove = (e, direction) => { const { focusedRegion } = this.state; if (focusedRegion == null) { // halt early if we have a selectedRegionTransform or something else in play that nixes // the focused cell. return; } const newFocusedRegion = TableHotkeys.moveFocusedRegionInDirection(focusedRegion, direction); if (this.isOutOfBounds(newFocusedRegion)) { return; } e.preventDefault(); e.stopPropagation(); // change selection to match new focus region location const newSelectionRegions = [Regions.getRegionFromFocusedRegion(newFocusedRegion)]; const { selectedRegionTransform } = this.props; const transformedSelectionRegions = selectedRegionTransform != null ? newSelectionRegions.map(region => selectedRegionTransform(region, e)) : newSelectionRegions; this.tableHandlers.handleSelection(transformedSelectionRegions); this.tableHandlers.handleFocus(newFocusedRegion); // keep the focused region in view this.scrollBodyToFocusedRegion(newFocusedRegion); }; static moveFocusedRegionInDirection(focusedRegion, direction) { switch (focusedRegion.type) { case FocusMode.CELL: return TableHotkeys.moveFocusedCellInDirection(focusedRegion, direction); case FocusMode.ROW: return TableHotkeys.moveFocusedRowInDirection(focusedRegion, direction); } } static moveFocusedRowInDirection(focusedRow, direction) { switch (direction) { case Direction.UP: return { ...focusedRow, focusSelectionIndex: 0, row: focusedRow.row - 1 }; case Direction.DOWN: return { ...focusedRow, focusSelectionIndex: 0, row: focusedRow.row + 1 }; case Direction.LEFT: case Direction.RIGHT: return { ...focusedRow }; } } static moveFocusedCellInDirection(focusedCell, direction) { switch (direction) { case Direction.UP: return { ...focusedCell, focusSelectionIndex: 0, row: focusedCell.row - 1 }; case Direction.DOWN: return { ...focusedCell, focusSelectionIndex: 0, row: focusedCell.row + 1 }; case Direction.LEFT: return { ...focusedCell, col: focusedCell.col - 1, focusSelectionIndex: 0 }; case Direction.RIGHT: return { ...focusedCell, col: focusedCell.col + 1, focusSelectionIndex: 0 }; } } // no good way to call arrow-key keyboard events from tests /* istanbul ignore next */ handleFocusMoveInternal = (e, direction) => { const { focusedRegion, selectedRegions } = this.state; if (focusedRegion?.type !== FocusMode.CELL) { // Move focus with in a selection is only supported for cell focus return; } let newFocusedCell = { ...focusedRegion }; // if we're not in any particular focus cell region, and one exists, go to the first cell of the first one if (focusedRegion.focusSelectionIndex == null && selectedRegions.length > 0) { const focusCellRegion = Regions.getCellRegionFromRegion(selectedRegions[0], this.grid.numRows, this.grid.numCols); newFocusedCell = { col: focusCellRegion.cols[0], focusSelectionIndex: 0, row: focusCellRegion.rows[0], type: FocusMode.CELL, }; } else { if (selectedRegions.length === 0) { this.handleFocusMove(e, direction); return; } const focusCellRegion = Regions.getCellRegionFromRegion(selectedRegions[focusedRegion.focusSelectionIndex], this.grid.numRows, this.grid.numCols); if (focusCellRegion.cols[0] === focusCellRegion.cols[1] && focusCellRegion.rows[0] === focusCellRegion.rows[1] && selectedRegions.length === 1) { this.handleFocusMove(e, direction); return; } switch (direction) { case Direction.UP: newFocusedCell = this.moveFocusCell("row", "col", true, newFocusedCell, focusCellRegion); break; case Direction.LEFT: newFocusedCell = this.moveFocusCell("col", "row", true, newFocusedCell, focusCellRegion); break; case Direction.DOWN: newFocusedCell = this.moveFocusCell("row", "col", false, newFocusedCell, focusCellRegion); break; case Direction.RIGHT: newFocusedCell = this.moveFocusCell("col", "row", false, newFocusedCell, focusCellRegion); break; default: break; } } if (this.isOutOfBounds(newFocusedCell)) { return; } e.preventDefault(); e.stopPropagation(); this.tableHandlers.handleFocus(newFocusedCell); // keep the focused cell in view this.scrollBodyToFocusedRegion(newFocusedCell); }; isOutOfBounds(focusedRegion) { const column = FocusedCellUtils.getFocusedColumn(focusedRegion) ?? 0; return (focusedRegion.row < 0 || focusedRegion.row >= this.grid.numRows || column < 0 || column >= this.grid.numCols); } scrollBodyToFocusedRegion = (focusedRegion) => { const { row } = focusedRegion; const col = FocusedCellUtils.getFocusedColumn(focusedRegion); const { viewportRect } = this.state; if (viewportRect === undefined || this.grid === undefined) { return; } const frozenRowsHeight = this.grid.getCumulativeHeightBefore(this.state.numFrozenRowsClamped); const frozenColumnsWidth = this.grid.getCumulativeWidthBefore(this.state.numFrozenColumnsClamped); // sort keys in normal CSS position order (per the trusty TRBL/"tr ouble" acronym) /* eslint-disable sort-keys */ const viewportBounds = { top: viewportRect.top, right: viewportRect.left + viewportRect.width, bottom: viewportRect.top + viewportRect.height, left: viewportRect.left, }; const { columnHeaderHeight, rowHeaderWidth } = this.tableHandlers.getHeaderDimensions(); // Bounds of the part of the viewport that contains visible, scrollable cells. const scrollableSectionBounds = { top: viewportBounds.top + columnHeaderHeight + frozenRowsHeight, right: viewportBounds.right, bottom: viewportBounds.bottom, left: viewportBounds.left + rowHeaderWidth + frozenColumnsWidth, }; // Cumulative col widths and row heights coordinates start do _not_ include header size. ViewportRect does. Add // header size so that we use the same origin. const focusedCellBounds = { top: this.grid.getCumulativeHeightBefore(row) + columnHeaderHeight, right: this.grid.getCumulativeWidthAt(col ?? 0) + rowHeaderWidth, bottom: this.grid.getCumulativeHeightAt(row) + columnHeaderHeight, left: this.grid.getCumulativeWidthBefore(col ?? 0) + rowHeaderWidth, }; /* eslint-enable sort-keys */ const ss = {}; // Vertical scroll const focusedCellHeight = focusedCellBounds.bottom - focusedCellBounds.top; const scrollableSectionHeight = scrollableSectionBounds.bottom - scrollableSectionBounds.top; const prevScrollTop = viewportBounds.top; if (focusedCellHeight > scrollableSectionHeight || focusedCellBounds.top < scrollableSectionBounds.top) { // scroll up (minus one pixel to avoid clipping the focused-cell border) ss.nextScrollTop = prevScrollTop - (scrollableSectionBounds.top - focusedCellBounds.top) - 1; } else if (scrollableSectionBounds.bottom < focusedCellBounds.bottom) { // scroll down ss.nextScrollTop = prevScrollTop + (focusedCellBounds.bottom - viewportBounds.bottom); } // Horizontal scroll if (col != null) { const focusedCellWidth = focusedCellBounds.right - focusedCellBounds.left; const scrollableSectionWidth = scrollableSectionBounds.right - scrollableSectionBounds.left; const prevScrollLeft = viewportBounds.left; if (focusedCellWidth > scrollableSectionWidth || focusedCellBounds.left < scrollableSectionBounds.left) { // scroll left (again minus one additional pixel) ss.nextScrollLeft = prevScrollLeft - (scrollableSectionBounds.left - focusedCellBounds.left) - 1; } else if (scrollableSectionBounds.right < focusedCellBounds.right) { // scroll right ss.nextScrollLeft = prevScrollLeft + (focusedCellBounds.right - viewportBounds.right); } } this.tableHandlers.syncViewportPosition(ss); }; // Quadrant refs // ============= moveFocusCell(primaryAxis, secondaryAxis, isUpOrLeft, newFocusedCell, focusCellRegion) { const { selectedRegions } = this.state; const primaryAxisPlural = primaryAxis === "row" ? "rows" : "cols"; const secondaryAxisPlural = secondaryAxis === "row" ? "rows" : "cols"; const movementDirection = isUpOrLeft ? -1 : +1; const regionIntervalIndex = isUpOrLeft ? 1 : 0; // try moving the cell in the direction along the primary axis newFocusedCell[primaryAxis] += movementDirection; const isPrimaryIndexOutOfBounds = isUpOrLeft ? newFocusedCell[primaryAxis] < focusCellRegion[primaryAxisPlural][0] : newFocusedCell[primaryAxis] > focusCellRegion[primaryAxisPlural][1]; if (isPrimaryIndexOutOfBounds) { // if we moved outside the bounds of selection region, // move to the start (or end) of the primary axis, and move one along the secondary newFocusedCell[primaryAxis] = focusCellRegion[primaryAxisPlural][regionIntervalIndex]; newFocusedCell[secondaryAxis] += movementDirection; const isSecondaryIndexOutOfBounds = isUpOrLeft ? newFocusedCell[secondaryAxis] < focusCellRegion[secondaryAxisPlural][0] : newFocusedCell[secondaryAxis] > focusCellRegion[secondaryAxisPlural][1]; if (isSecondaryIndexOutOfBounds) { // if moving along the secondary also moves us outside // go to the start (or end) of the next (or previous region) // (note that if there's only one region you'll be moving to the opposite corner, which is fine) let newFocusCellSelectionIndex = newFocusedCell.focusSelectionIndex + movementDirection; // newFocusCellSelectionIndex should be one more (or less), unless we need to wrap around if (isUpOrLeft ? newFocusCellSelectionIndex < 0 : newFocusCellSelectionIndex >= selectedRegions.length) { newFocusCellSelectionIndex = isUpOrLeft ? selectedRegions.length - 1 : 0; } const newFocusCellRegion = Regions.getCellRegionFromRegion(selectedRegions[newFocusCellSelectionIndex], this.grid.numRows, this.grid.numCols); newFocusedCell = { col: newFocusCellRegion.cols[regionIntervalIndex], focusSelectionIndex: newFocusCellSelectionIndex, row: newFocusCellRegion.rows[regionIntervalIndex], type: FocusMode.CELL, }; } } return newFocusedCell; } handleCopy = (e) => { const { getCellClipboardData, onCopy } = this.props; const { selectedRegions } = this.state; if (getCellClipboardData == null || this.grid === undefined || window.getSelection()?.toString()) { return; } // prevent "real" copy from being called e.preventDefault(); e.stopPropagation(); const cells = Regions.enumerateUniqueCells(selectedRegions, this.grid.numRows, this.grid.numCols); // non-null assertion because Column.defaultProps.cellRenderer is defined const sparse = Regions.sparseMapCells(cells, (row, col) => getCellClipboardData(row, col, this.state.childrenArray[col].props.cellRenderer)); if (sparse != null) { Clipboard.copyCells(sparse) .then(() => onCopy?.(true)) .catch((reason) => { console.error(TABLE_COPY_FAILED, reason); onCopy?.(false); }); } }; } //# sourceMappingURL=tableHotkeys.js.map