Skip to content

Commit

Permalink
Better handling of mouse events in the table body. (#427)
Browse files Browse the repository at this point in the history
Previously, mouse events in the table body were used for selecting regions. With
the addition of interactive cells such as `EditableCell` components, we must now
provide full mouse interativity to parts of the table body.

Previously, two instances of `Draggable` were contending for mouse events. The
first was waiting for double clicks to focus the `EditableCell`. The second was
listening for clicks and drags for region selection. Both of these were invoking
`preventDefault`, which was preventing text selection from working withing the
`EditableCell` even when it was in edit mode.

This change adds new props to `Draggable` to control the use of `preventDefault`
and `stopPropagation` on the mouse events. We also added an `iteractive` prop to
the `Cell` component, which applies a z-index that brings the cell above the
region layer.

Now, the table body will listen for clicks and drags for selection.
`EditableCell`s will listen for double clicks to start editing. When editing is
begun, the cell become "interative" and it is moved to a z-index above the
selection regions layer. Also during edit mode, the `preventDefault` prop is
disabled and `stopPropagation` is enabled to prevent the table body from messing
with selected regions. This prevents an issue where the user starts dragging a
text selection in an `EditableCell` and ends up selecting regions.

In addition, we performed some cleanup on the table body:

Consolidated ghost and non-ghost render methods, which were nearly identical.

Moved selection interaction component to top-level of table body instead of
attaching listeners to individual cells. This will reduce the total number of
event listeners attached to DOM elements. However, it did necessitate the
creation of a small wrapper to use the `ContextMenuTarget` decorator.
  • Loading branch information
themadcreator authored and giladgray committed Jan 12, 2017
1 parent 899f564 commit fc0e8d4
Show file tree
Hide file tree
Showing 13 changed files with 240 additions and 90 deletions.
1 change: 1 addition & 0 deletions packages/table/preview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ const renderBodyContextMenu = (context: IMenuContext) => {

ReactDOM.render(
getTableComponent(3, 7, {}, {
allowMultipleSelection: true,
renderBodyContextMenu,
selectionModes: SelectionModes.ALL,
isColumnResizable: true,
Expand Down
4 changes: 4 additions & 0 deletions packages/table/src/cell/_cell.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
}
}

.bp-table-cell-interactive {
z-index: $interactive-cell-z-index;
}

.bp-table-striped {
.bp-table-cell-ledger-even {
background-color: $cell-background-color;
Expand Down
34 changes: 30 additions & 4 deletions packages/table/src/cell/cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export interface ICellProps extends IIntentProps, IProps {
style?: React.CSSProperties;

/**
* If true, the cell will be rendered above overlay layers to enable mouse
* interactions within the cell.
* @default false
*/
interactive?: boolean;

/**
* An optional native tooltip that is displayed on hover
* If true, content will be replaced with a fixed-height skeleton.
* @default false
*/
Expand All @@ -28,30 +36,48 @@ export interface ICellProps extends IIntentProps, IProps {
* An optional native tooltip that is displayed on hover.
*/
tooltip?: string;

/**
* If true, the cell contents will be wrapped in a div with
* styling that will prevent the content from overflowing the cell.
* @default true
*/
truncated?: boolean;
}

export type ICellRenderer = (rowIndex: number, columnIndex: number) => React.ReactElement<ICellProps>;

export const emptyCellRenderer = (_rowIndex: number, _columnIndex: number) => <Cell />;

export const CELL_CLASSNAME = "bp-table-cell";
export const CELL_INTERACTIVE_CLASSNAME = "bp-table-cell-interactive";

@PureRender
export class Cell extends React.Component<ICellProps, {}> {
public static defaultProps = {
truncated: true,
};

public render() {
const { style, loading, tooltip, className } = this.props;
const { style, intent, interactive, loading, tooltip, truncated, className } = this.props;

const classes = classNames(
CELL_CLASSNAME,
Classes.intentClass(this.props.intent),
{ [Classes.LOADING]: loading },
Classes.intentClass(intent),
{
[CELL_INTERACTIVE_CLASSNAME]: interactive,
[Classes.LOADING]: loading,
},
className,
);

const content = truncated ?
<div className="bp-table-truncated-text">{this.props.children}</div> : this.props.children;

return (
<div className={classes} style={style} title={tooltip}>
<LoadableContent loading={loading} variableLength={true}>
<div className="bp-table-truncated-text">{this.props.children}</div>
{content}
</LoadableContent>
</div>
);
Expand Down
71 changes: 51 additions & 20 deletions packages/table/src/cell/editableCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

import { Classes, EditableText } from "@blueprintjs/core";
import * as classNames from "classnames";
import { EditableText, Utils } from "@blueprintjs/core";
import * as PureRender from "pure-render-decorator";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Draggable } from "../interactions/draggable";
import { ICellProps } from "./cell";
import { Cell, ICellProps } from "./cell";

export interface IEditableCellProps extends ICellProps {
/**
Expand Down Expand Up @@ -40,45 +41,75 @@ export interface IEditableCellProps extends ICellProps {
onConfirm?: (value: string) => void;
}

export class EditableCell extends React.Component<IEditableCellProps, {}> {
private cellElement: HTMLElement;
export interface IEditableCellState {
isEditing: boolean;
}

@PureRender
export class EditableCell extends React.Component<IEditableCellProps, IEditableCellState> {
public state = {
isEditing: false,
};

public render() {
const { className, value, intent, onCancel, onChange, onConfirm, style, tooltip } = this.props;
const { value, intent, onChange } = this.props;
const { isEditing } = this.state;
const interactive = this.props.interactive || isEditing;

return (
<div
className={classNames(className, Classes.intentClass(intent), "bp-table-cell")}
style={style}
title={tooltip}
ref={this.handleCellRef}
>
<Draggable onDoubleClick={this.handleCellDoubleClick}>
<Cell {...this.props} truncated={false} interactive={interactive}>
<Draggable
onActivate={this.handleCellActivate}
onDoubleClick={this.handleCellDoubleClick}
preventDefault={!interactive}
stopPropagation={interactive}
>
<EditableText
className={"bp-table-editable-name"}
defaultValue={value}
intent={intent}
minWidth={null}
onCancel={onCancel}
onCancel={this.handleCancel}
onChange={onChange}
onConfirm={onConfirm}
onConfirm={this.handleConfirm}
onEdit={this.handleEdit}
placeholder=""
selectAllOnFocus={true}
/>
</Draggable>
</div>
</Cell>
);
}

private handleCellRef = (ref: HTMLElement) => {
this.cellElement = ref;
private handleEdit = () => {
this.setState({ isEditing: true });
}

private handleCancel = (value: string) => {
this.setState({ isEditing: false });
Utils.safeInvoke(this.props.onCancel, value);
}

private handleConfirm = (value: string) => {
this.setState({ isEditing: false });
Utils.safeInvoke(this.props.onConfirm, value);
}

private handleCellActivate = (_event: MouseEvent) => {
// Cancel edit of active cell when clicking away
if (!this.state.isEditing && document.activeElement instanceof HTMLElement && document.activeElement.blur) {
document.activeElement.blur();
}
return true;
}

private handleCellDoubleClick = (_event: MouseEvent) => {
if (this.cellElement == null) {
const cellElement = ReactDOM.findDOMNode(this) as HTMLElement;
if (cellElement == null) {
return;
}

const focusable = (this.cellElement.querySelector(".pt-editable-text") as HTMLElement);
const focusable = (cellElement.querySelector(".pt-editable-text") as HTMLElement);
if (focusable.focus != null) {
focusable.focus();
}
Expand Down
6 changes: 6 additions & 0 deletions packages/table/src/common/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ $resize-horizontal-cursor: ns-resize !default;

// Actions
$action-cursor: pointer !default;

/*
Z-index layers
*/
$region-layer-z-index: $pt-z-index-overlay !default;
$interactive-cell-z-index: $pt-z-index-overlay + 1 !default;
32 changes: 32 additions & 0 deletions packages/table/src/common/contextMenuTargetWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright 2017 Palantir Technologies, Inc. All rights reserved.
* Licensed under the BSD-3 License as modified (the “License”); you may obtain a copy
* of the license at https://github.com/palantir/blueprint/blob/master/LICENSE
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

import { ContextMenuTarget, IProps } from "@blueprintjs/core";
import * as React from "react";

export interface IContextMenuTargetWrapper extends IProps {
renderContextMenu: (e: React.MouseEvent<HTMLElement>) => JSX.Element;
style: React.CSSProperties;
}

/**
* Since the ContextMenuTarget uses the `onContextMenu` prop instead
* `element.addEventListener`, the prop can be lost. This wrapper helps us
* maintain context menu fuctionality when doing fancy React.cloneElement
* chains.
*/
@ContextMenuTarget
export class ContextMenuTargetWrapper extends React.Component<IContextMenuTargetWrapper, {}> {
public render() {
const { className, children, style } = this.props;
return <div className={className} style={style}>{children}</div>;
}

public renderContextMenu(e: React.MouseEvent<HTMLElement>) {
return this.props.renderContextMenu(e);
}
}
13 changes: 13 additions & 0 deletions packages/table/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,17 @@ export const Utils = {
}
return value;
},

/**
* Partial shallow comparison between objects using the given list of keys.
*/
shallowCompareKeys(objA: any, objB: any, keys: string[]) {
for (const key of keys) {
if (objA[key] !== objB[key]) {
return false;
}
}
return true;
},

};
21 changes: 17 additions & 4 deletions packages/table/src/interactions/dragEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,16 @@ export class DragEvents {
return data;
}

private handleMouseDown = (event: MouseEvent) => {
private maybeAlterEventChain(event: MouseEvent) {
if (this.handler.preventDefault) {
event.preventDefault();
}
if (this.handler.stopPropagation) {
event.stopPropagation();
}
}

private handleMouseDown = (event: MouseEvent) => {
this.initCoordinateData(event);

if (this.handler != null && this.handler.onActivate != null) {
Expand All @@ -97,12 +105,17 @@ export class DragEvents {
}

this.isActivated = true;
event.preventDefault();
this.maybeAlterEventChain(event);

// It is possible that the mouseup would not be called after the initial
// mousedown (for example if the mouse is moved out of the window). So,
// we preemptively detach to avoid duplicate listeners.
this.detachDocumentEventListeners();
this.attachDocumentEventListeners();
}

private handleMouseMove = (event: MouseEvent) => {
event.preventDefault();
this.maybeAlterEventChain(event);

if (this.isActivated) {
this.isDragging = true;
Expand All @@ -118,7 +131,7 @@ export class DragEvents {
}

private handleMouseUp = (event: MouseEvent) => {
event.preventDefault();
this.maybeAlterEventChain(event);

if (this.handler != null) {
if (this.isDragging) {
Expand Down
31 changes: 30 additions & 1 deletion packages/table/src/interactions/draggable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as PureRender from "pure-render-decorator";
import * as React from "react";
import * as ReactDOM from "react-dom";

import { Utils } from "../common/utils";
import { DragEvents } from "./dragEvents";

export type IClientCoordinates = [number, number];
Expand Down Expand Up @@ -49,7 +50,6 @@ export interface IDragHandler {
/**
* Called when the mouse is pressed down. Drag and click operations may
* be cancelled at this point by returning false from this method.
* Otherwise, `stopPropagation` is called on the event.
*/
onActivate?: (event: MouseEvent) => boolean;

Expand Down Expand Up @@ -86,11 +86,29 @@ export interface IDragHandler {
* event.
*/
onDoubleClick?: (event: MouseEvent) => void;

/**
* This prevents mouse events from performing their default operation such
* as text selection.
* @default true
*/
preventDefault?: boolean;

/**
* This prevents the event from propagating up to parent elements.
* @default false
*/
stopPropagation?: boolean;
}

export interface IDraggableProps extends IProps, IDragHandler {
}

const REATTACH_PROPS_KEYS = [
"stopPropagation",
"preventDefault",
];

/**
* This component provides a simple interface for combined drag and/or click
* events.
Expand Down Expand Up @@ -118,12 +136,23 @@ export interface IDraggableProps extends IProps, IDragHandler {
*/
@PureRender
export class Draggable extends React.Component<IDraggableProps, {}> {
public static defaultProps = {
preventDefault: true,
stopPropagation: false,
};

private events: DragEvents;

public render() {
return React.Children.only(this.props.children);
}

public componentWillReceiveProps(nextProps: IDraggableProps) {
if (this.events && !Utils.shallowCompareKeys(this.props, nextProps, REATTACH_PROPS_KEYS)) {
this.events.attach(ReactDOM.findDOMNode(this) as HTMLElement, nextProps);
}
}

public componentDidMount() {
this.events = new DragEvents();
this.events.attach(ReactDOM.findDOMNode(this) as HTMLElement, this.props);
Expand Down
2 changes: 1 addition & 1 deletion packages/table/src/interactions/selectable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class DragSelectable extends React.Component<IDragSelectableProps, {}> {
public render() {
const draggableProps = this.getDraggableProps();
return (
<Draggable {...draggableProps}>
<Draggable {...draggableProps} preventDefault={false}>
{this.props.children}
</Draggable>
);
Expand Down
1 change: 1 addition & 0 deletions packages/table/src/layers/_layers.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ $region-selected-color: $pt-intent-primary !default;
right: 0;
bottom: 0;
left: 0;
z-index: $region-layer-z-index;
pointer-events: none;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/table/src/table.scss
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ $menu-z-index: $row-z-index + 1 !default;

.bp-table-body-scroll-client {
position: relative;
user-select: none;
will-change: transform; // isolate stacking context for interacive cells
}

.bp-table-body-virtual-client {
Expand Down
Loading

1 comment on commit fc0e8d4

@blueprint-bot
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better handling of mouse events in the table body. (#427)

Preview: docs
Coverage: core | datetime

Please sign in to comment.