From 24dd6260f985a40dc65fb19b5c1add9f13c53c7a Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 1 Aug 2016 08:27:15 -0700 Subject: [PATCH 01/27] Adding autoscroll utility and marquee selection. --- src/common/BaseComponent.ts | 9 + .../examples/Selection.Basic.Example.tsx | 35 +-- src/utilities/selection/AutoScroll.ts | 65 ++++++ src/utilities/selection/MarqueeSelection.scss | 23 ++ src/utilities/selection/MarqueeSelection.tsx | 201 ++++++++++++++++++ src/utilities/selection/index.ts | 3 +- 6 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 src/utilities/selection/AutoScroll.ts create mode 100644 src/utilities/selection/MarqueeSelection.scss create mode 100644 src/utilities/selection/MarqueeSelection.tsx diff --git a/src/common/BaseComponent.ts b/src/common/BaseComponent.ts index 3829a401311b6..15824d361d8c4 100644 --- a/src/common/BaseComponent.ts +++ b/src/common/BaseComponent.ts @@ -47,6 +47,15 @@ export class BaseComponent extends React.Component { return (results && results.length > 1) ? results[1] : ''; } + /** Gives subclasses a way to automatically bind methods that are prefixed with "_on". */ + protected autoBindCallbacks(prototype: Object) { + for (let methodName in prototype) { + if (methodName.indexOf('_on') === 0) { + this[methodName] = this[methodName].bind(this); + } + } + } + /** Allows subclasses to push things to this._disposables to be auto disposed. */ protected get _disposables(): IDisposable[] { if (!this.__disposables) { diff --git a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx index 1f995cf48cce7..b8419fdfaffbf 100644 --- a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx +++ b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx @@ -6,7 +6,8 @@ import { ISelection, Selection, SelectionMode, - SelectionZone + SelectionZone, + MarqueeSelection } from '../../../../utilities/selection/index'; import { createListItems } from '../../../utilities/data'; @@ -57,21 +58,23 @@ export class SelectionBasicExample extends React.Component - - - { items.map((item, index) => ( - - )) } - - + +
+ + + { items.map((item, index) => ( + + )) } + +
+
); } diff --git a/src/utilities/selection/AutoScroll.ts b/src/utilities/selection/AutoScroll.ts new file mode 100644 index 0000000000000..8fa07befc33c4 --- /dev/null +++ b/src/utilities/selection/AutoScroll.ts @@ -0,0 +1,65 @@ +import { EventGroup } from '../eventGroup/EventGroup'; +import { findScrollableParent } from '../scrollUtilities'; + +const SCROLL_ITERATION_DELAY = 30; +const SCROLL_GUTTER_HEIGHT = 150; +const MAX_SCROLL_VELOCITY = 25; + +export class AutoScroll { + private _events: EventGroup; + private _scrollableParent: HTMLElement; + private _scrollVelocity: number; + private _intervalId: number; + + constructor(element: HTMLElement) { + this._events = new EventGroup(this); + this._scrollableParent = findScrollableParent(element); + + if (this._scrollableParent) { + this._events.on(window, 'mousemove', this._onMouseMove, true); + } + } + + public dispose() { + this._events.dispose(); + this._stopInterval(); + } + + private _onMouseMove(ev: MouseEvent) { + let scrollRect = this._scrollableParent.getBoundingClientRect(); + let scrollClientBottom = scrollRect.top + scrollRect.height - SCROLL_GUTTER_HEIGHT; + + if (ev.clientY < (scrollRect.top + SCROLL_GUTTER_HEIGHT)) { + this._scrollVelocity = Math.max( + -MAX_SCROLL_VELOCITY, + -MAX_SCROLL_VELOCITY * ((SCROLL_GUTTER_HEIGHT - (ev.clientY - scrollRect.top)) / SCROLL_GUTTER_HEIGHT + )); + } else if (ev.clientY > scrollClientBottom) { + this._scrollVelocity = Math.min( + MAX_SCROLL_VELOCITY, + MAX_SCROLL_VELOCITY * ((ev.clientY - scrollClientBottom) / SCROLL_GUTTER_HEIGHT + )); + } else { + this._scrollVelocity = 0; + } + + if (this._scrollVelocity) { + this._startInterval(); + } + } + + private _startInterval() { + if (!this._intervalId) { + this._intervalId = setInterval(() => { + this._scrollableParent.scrollTop += this._scrollVelocity; + }, SCROLL_ITERATION_DELAY); + } + } + + private _stopInterval() { + if (this._intervalId) { + clearInterval(this._intervalId); + delete this._intervalId; + } + } +} \ No newline at end of file diff --git a/src/utilities/selection/MarqueeSelection.scss b/src/utilities/selection/MarqueeSelection.scss new file mode 100644 index 0000000000000..fae620b1ad4eb --- /dev/null +++ b/src/utilities/selection/MarqueeSelection.scss @@ -0,0 +1,23 @@ +@import '../../common/common'; + +.ms-MarqueeSelection { + position: relative; + cursor: default; +} + +.ms-MarqueeSelection-box { + position: absolute; + box-sizing: border-box; + border: 1px solid $ms-color-themePrimary; +} + +.ms-MarqueeSelection-boxFill { + position: absolute; + box-sizing: border-box; + background-color: $ms-color-themePrimary; + opacity: .1; + left: 0; + top: 0; + right: 0; + bottom: 0; +} diff --git a/src/utilities/selection/MarqueeSelection.tsx b/src/utilities/selection/MarqueeSelection.tsx new file mode 100644 index 0000000000000..ade9a65d955ef --- /dev/null +++ b/src/utilities/selection/MarqueeSelection.tsx @@ -0,0 +1,201 @@ +import * as React from 'react'; +import { BaseComponent } from '../../common/BaseComponent'; +import { css } from '../../utilities/css'; +import { ISelection } from './interfaces'; +import { findScrollableParent } from '../scrollUtilities'; +import { AutoScroll } from './AutoScroll'; +import './MarqueeSelection.scss'; + +export interface IPoint { + x: number; + y: number; +} + +export interface IRectangle { + left: number; + top: number; + width: number; + height: number; + + right?: number; + bottom?: number; +} + +export interface IMarqueeSelectionProps extends React.Props { + baseElement?: string; + className?: string; + selection?: ISelection; +} + +export interface IMarqueeSelectionState { + dragOrigin?: IPoint, + dragRect?: IRectangle +} + +const MIN_DRAG_DISTANCE = 10; + +export class MarqueeSelection extends BaseComponent { + public static defaultProps = { + baseElement: 'div' + }; + + public refs: { + [key: string]: React.ReactInstance; + root: HTMLElement; + }; + + private _dragOrigin: IPoint; + private _rootRect: IRectangle; + private _lastMouseEvent: React.MouseEvent; + private _autoScroll; + + constructor(props: IMarqueeSelectionProps) { + super(props); + + this.state = { + dragRect: null + }; + + this.autoBindCallbacks(MarqueeSelection.prototype); + } + + public componentWillUnmount() { + if (this._autoScroll) { + this._autoScroll.dispose(); + } + } + + public render(): JSX.Element { + let { baseElement, className, children } = this.props; + let { dragRect } = this.state; + let selectionBox = null; + + if (dragRect) { + selectionBox = ( +
+
+
+ ); + } + + return React.createElement( + baseElement, + { + className: css('ms-MarqueeSelection', className), + ref: 'root', + onMouseDown: this._onMouseDown + }, + children, + selectionBox + ); + } + + private _onMouseDown(ev: React.MouseEvent) { + this._events.on(window, 'mousemove', this._onMouseMove, true); + this._events.on(findScrollableParent(this.refs.root), 'scroll', this._onMouseMove); + this._events.on(window, 'mouseup', this._onMouseUp, true); + this._autoScroll = new AutoScroll(this.refs.root); + this._onMouseMove(ev); + } + + private _onMouseMove(ev: React.MouseEvent) { + if (ev.clientX !== undefined) { + this._lastMouseEvent = ev; + } + + this._rootRect = this.refs.root.getBoundingClientRect(); + + let currentPoint = { x: ev.clientX - this._rootRect.left, y: ev.clientY - this._rootRect.top }; + + if (!this._dragOrigin) { + this._dragOrigin = currentPoint; + } + + if (ev.buttons === 0) { + this._onMouseUp(ev); + } else { + if (this.state.dragRect || _getDistanceBetweenPoints(this._dragOrigin, currentPoint) > MIN_DRAG_DISTANCE) { + let point = { + x: Math.max(0, Math.min(this._rootRect.width, this._lastMouseEvent.clientX - this._rootRect.left)), + y: Math.max(0, Math.min(this._rootRect.height, this._lastMouseEvent.clientY - this._rootRect.top)) + }; + + this.setState({ + dragRect: { + left: Math.min(this._dragOrigin.x, point.x), + top: Math.min(this._dragOrigin.y, point.y), + width: Math.abs(point.x - this._dragOrigin.x), + height: Math.abs(point.y - this._dragOrigin.y) + } + }, () => this._asyncEvaluateSelection()); + } + + ev.preventDefault(); + ev.stopPropagation(); + } + } + + private _onMouseUp(ev: React.MouseEvent) { + this._events.off(); + this._autoScroll.dispose(); + this._autoScroll = null; + this._dragOrigin = null; + this._lastMouseEvent = null; + + if (this.state.dragRect) { + this.setState({ + dragRect: null + }); + + ev.preventDefault(); + ev.stopPropagation(); + } + } + + private _asyncEvaluateSelection() { + let { selection } = this.props; + let { dragRect } = this.state; + let allElements = this.refs.root.querySelectorAll('[data-selection-index]'); + + // Normalize the dragRect to document coordinates. + dragRect = { + left: this._rootRect.left + dragRect.left, + top: this._rootRect.top + dragRect.top, + width: dragRect.width, + height: dragRect.height, + bottom: 0, + right: 0, + }; + + // Provide right/bottom values for easy compares. + dragRect.right = dragRect.left + dragRect.width; + dragRect.bottom = dragRect.top + dragRect.height; + + selection.setChangeEvents(false); + + selection.setAllSelected(false); + + for (let i = 0; i < allElements.length; i++) { + let element = allElements[i]; + let index = Number(element.getAttribute('data-selection-index')); + let itemRect = element.getBoundingClientRect(); + + if ( + itemRect.top < dragRect.bottom && + itemRect.bottom > dragRect.top && + itemRect.left < dragRect.right && + itemRect.right > dragRect.left + ) { + selection.setIndexSelected(index, true, false); + } + } + + selection.setChangeEvents(true); + } +} + +function _getDistanceBetweenPoints(point1: IPoint, point2: IPoint): number { + let distance = Math.sqrt(Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2)); + + return distance; +} \ No newline at end of file diff --git a/src/utilities/selection/index.ts b/src/utilities/selection/index.ts index 38495fe20d46e..2f2403065cffd 100644 --- a/src/utilities/selection/index.ts +++ b/src/utilities/selection/index.ts @@ -1,4 +1,5 @@ export * from './interfaces'; export * from './Selection'; export * from './SelectionLayout'; -export * from './SelectionZone'; \ No newline at end of file +export * from './SelectionZone'; +export * from './MarqueeSelection'; From 3921752e3e0e75168aabb1f1d2e84c1a87cd1bd8 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Tue, 2 Aug 2016 21:47:34 -0700 Subject: [PATCH 02/27] More fixes. --- src/components/DetailsList/DetailsHeader.tsx | 14 +- src/components/DetailsList/DetailsList.tsx | 135 +++++++++--------- src/utilities/selection/AutoScroll.ts | 6 +- .../selection/MarqueeSelection.Props.ts | 24 ++++ src/utilities/selection/MarqueeSelection.scss | 10 ++ src/utilities/selection/MarqueeSelection.tsx | 84 +++++------ src/utilities/selection/SelectionZone.tsx | 4 +- 7 files changed, 159 insertions(+), 118 deletions(-) create mode 100644 src/utilities/selection/MarqueeSelection.Props.ts diff --git a/src/components/DetailsList/DetailsHeader.tsx b/src/components/DetailsList/DetailsHeader.tsx index 509c3ce4edcc9..f65624d063f76 100644 --- a/src/components/DetailsList/DetailsHeader.tsx +++ b/src/components/DetailsList/DetailsHeader.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { BaseComponent } from '../../common/BaseComponent'; import { IColumn, DetailsListLayoutMode, ColumnActionsMode } from './DetailsList.Props'; import { FocusZone, FocusZoneDirection } from '../../FocusZone'; import { Check } from '../Check/Check'; @@ -47,19 +48,15 @@ export interface IColumnResizeDetails { columnMinWidth: number; } -export class DetailsHeader extends React.Component { +export class DetailsHeader extends BaseComponent { public refs: { [key: string]: React.ReactInstance; focusZone: FocusZone; }; - private _events: EventGroup; - constructor(props: IDetailsHeaderProps) { super(props); - this._events = new EventGroup(this); - this.state = { columnResizeDetails: null, groupNestingDepth: this.props.groupNestingDepth, @@ -76,10 +73,6 @@ export class DetailsHeader extends React.Component -
- { isHeaderVisible && ( - - ) } -
-
- (ev.which === getRTLSafeKeyCode(KeyCodes.right)) } - onActiveElementChanged={ this._onActiveRowChanged } - > - - { groups ? ( + +
+ { isHeaderVisible && ( + + ) } +
+
+ (ev.which === getRTLSafeKeyCode(KeyCodes.right)) } + onActiveElementChanged={ this._onActiveRowChanged } + > + + { groups ? ( ) : ( - this._onRenderCell(0, item, itemIndex) } - { ...additionalListProps } - ref='list' - /> - ) - } - - -
+ this._onRenderCell(0, item, itemIndex) } + { ...additionalListProps } + ref='list' + /> + ) + } +
+
+
+
); } @@ -303,25 +306,25 @@ export class DetailsList extends React.Component + ); } @@ -485,9 +488,9 @@ export class DetailsList extends React.Component 0 ? DEFAULT_INNER_PADDING : 0); + totalWidth += newColumn.calculatedWidth + (i > 0 ? DEFAULT_INNER_PADDING : 0); - return newColumn; + return newColumn; }); let lastIndex = adjustedColumns.length - 1; diff --git a/src/utilities/selection/AutoScroll.ts b/src/utilities/selection/AutoScroll.ts index 8fa07befc33c4..879bd06b796df 100644 --- a/src/utilities/selection/AutoScroll.ts +++ b/src/utilities/selection/AutoScroll.ts @@ -2,8 +2,8 @@ import { EventGroup } from '../eventGroup/EventGroup'; import { findScrollableParent } from '../scrollUtilities'; const SCROLL_ITERATION_DELAY = 30; -const SCROLL_GUTTER_HEIGHT = 150; -const MAX_SCROLL_VELOCITY = 25; +const SCROLL_GUTTER_HEIGHT = 100; +const MAX_SCROLL_VELOCITY = 20; export class AutoScroll { private _events: EventGroup; @@ -45,6 +45,8 @@ export class AutoScroll { if (this._scrollVelocity) { this._startInterval(); + } else { + this._stopInterval(); } } diff --git a/src/utilities/selection/MarqueeSelection.Props.ts b/src/utilities/selection/MarqueeSelection.Props.ts new file mode 100644 index 0000000000000..aff71a751e6b3 --- /dev/null +++ b/src/utilities/selection/MarqueeSelection.Props.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { ISelection } from './interfaces'; +import { MarqueeSelection } from './MarqueeSelection'; + +export interface IPoint { + x: number; + y: number; +} + +export interface IRectangle { + left: number; + top: number; + width: number; + height: number; + + right?: number; + bottom?: number; +} + +export interface IMarqueeSelectionProps extends React.Props { + baseElement?: string; + className?: string; + selection?: ISelection; +} diff --git a/src/utilities/selection/MarqueeSelection.scss b/src/utilities/selection/MarqueeSelection.scss index fae620b1ad4eb..2efac9dae19c2 100644 --- a/src/utilities/selection/MarqueeSelection.scss +++ b/src/utilities/selection/MarqueeSelection.scss @@ -5,10 +5,20 @@ cursor: default; } +.ms-MarqueeSelection-dragMask { + position: absolute; + background: rgba(255, 0, 0, 0); + left: 0; + top: 0; + right: 0; + bottom: 0; +} + .ms-MarqueeSelection-box { position: absolute; box-sizing: border-box; border: 1px solid $ms-color-themePrimary; + pointer-events: none; } .ms-MarqueeSelection-boxFill { diff --git a/src/utilities/selection/MarqueeSelection.tsx b/src/utilities/selection/MarqueeSelection.tsx index ade9a65d955ef..ef552896f7972 100644 --- a/src/utilities/selection/MarqueeSelection.tsx +++ b/src/utilities/selection/MarqueeSelection.tsx @@ -1,31 +1,14 @@ import * as React from 'react'; import { BaseComponent } from '../../common/BaseComponent'; import { css } from '../../utilities/css'; -import { ISelection } from './interfaces'; import { findScrollableParent } from '../scrollUtilities'; import { AutoScroll } from './AutoScroll'; import './MarqueeSelection.scss'; - -export interface IPoint { - x: number; - y: number; -} - -export interface IRectangle { - left: number; - top: number; - width: number; - height: number; - - right?: number; - bottom?: number; -} - -export interface IMarqueeSelectionProps extends React.Props { - baseElement?: string; - className?: string; - selection?: ISelection; -} +import { + IRectangle, + IPoint, + IMarqueeSelectionProps +} from './MarqueeSelection.Props'; export interface IMarqueeSelectionState { dragOrigin?: IPoint, @@ -46,7 +29,7 @@ export class MarqueeSelection extends BaseComponent + ); selectionBox = (
@@ -86,19 +77,24 @@ export class MarqueeSelection extends BaseComponent MIN_DRAG_DISTANCE) { - let point = { + // We need to constain the current point to the rootRect boundaries. + let constrainedPoint = { x: Math.max(0, Math.min(this._rootRect.width, this._lastMouseEvent.clientX - this._rootRect.left)), y: Math.max(0, Math.min(this._rootRect.height, this._lastMouseEvent.clientY - this._rootRect.top)) }; this.setState({ dragRect: { - left: Math.min(this._dragOrigin.x, point.x), - top: Math.min(this._dragOrigin.y, point.y), - width: Math.abs(point.x - this._dragOrigin.x), - height: Math.abs(point.y - this._dragOrigin.y) + left: Math.min(this._dragOrigin.x, constrainedPoint.x), + top: Math.min(this._dragOrigin.y, constrainedPoint.y), + width: Math.abs(constrainedPoint.x - this._dragOrigin.x), + height: Math.abs(constrainedPoint.y - this._dragOrigin.y) } }, () => this._asyncEvaluateSelection()); } - - ev.preventDefault(); - ev.stopPropagation(); } + + return false; } - private _onMouseUp(ev: React.MouseEvent) { - this._events.off(); + private _onMouseUp(ev: MouseEvent) { + let scrollableParent = findScrollableParent(this.refs.root); + + this._events.off(window); + this._events.off(scrollableParent, 'scroll'); + this._autoScroll.dispose(); this._autoScroll = null; this._dragOrigin = null; @@ -155,6 +155,8 @@ export class MarqueeSelection extends BaseComponent { index = Number(indexString); break; } - element = element.parentElement; + if (element !== this.refs.root) { + element = element.parentElement; + } } while (traverseParents && element !== this.refs.root); return index; From 58281d98fe529e70fafa62da6539023211ba1bc3 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Tue, 2 Aug 2016 22:45:04 -0700 Subject: [PATCH 03/27] Enables selection preservation even when items dematerialize. --- src/utilities/selection/MarqueeSelection.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/utilities/selection/MarqueeSelection.tsx b/src/utilities/selection/MarqueeSelection.tsx index ef552896f7972..cea3a5e92a0d8 100644 --- a/src/utilities/selection/MarqueeSelection.tsx +++ b/src/utilities/selection/MarqueeSelection.tsx @@ -31,6 +31,7 @@ export class MarqueeSelection extends BaseComponent dragRect.left ) { - selection.setIndexSelected(index, true, false); + this._selectedIndicies[index] = true; + } else { + delete this._selectedIndicies[index]; + } + } + + for (let index in this._selectedIndicies) { + if (this._selectedIndicies.hasOwnProperty(index)) { + selection.setIndexSelected(Number(index), true, false); } } From c4ced040099a3c1e3c7c81a173de0bc49eb120e7 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Tue, 2 Aug 2016 22:51:21 -0700 Subject: [PATCH 04/27] Math rounding tweak in auto scrolling. --- src/utilities/selection/AutoScroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/selection/AutoScroll.ts b/src/utilities/selection/AutoScroll.ts index 879bd06b796df..212b60339f510 100644 --- a/src/utilities/selection/AutoScroll.ts +++ b/src/utilities/selection/AutoScroll.ts @@ -53,7 +53,7 @@ export class AutoScroll { private _startInterval() { if (!this._intervalId) { this._intervalId = setInterval(() => { - this._scrollableParent.scrollTop += this._scrollVelocity; + this._scrollableParent.scrollTop += Math.round(this._scrollVelocity); }, SCROLL_ITERATION_DELAY); } } From 2e0a11980b158d95642ba05fc296a2823e70b7c7 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Wed, 3 Aug 2016 13:49:57 -0700 Subject: [PATCH 05/27] Updating small nits. --- src/components/DetailsList/DetailsHeader.tsx | 1 - src/utilities/selection/MarqueeSelection.tsx | 4 ++-- src/utilities/selection/Selection.ts | 20 +++++++++----------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/components/DetailsList/DetailsHeader.tsx b/src/components/DetailsList/DetailsHeader.tsx index f65624d063f76..422e6a78ca278 100644 --- a/src/components/DetailsList/DetailsHeader.tsx +++ b/src/components/DetailsList/DetailsHeader.tsx @@ -7,7 +7,6 @@ import { GroupSpacer } from '../GroupedList/GroupSpacer'; import { css } from '../../utilities/css'; import { ISelection, SelectionMode, SELECTION_CHANGE } from '../../utilities/selection/interfaces'; import { getRTL } from '../../utilities/rtl'; -import { EventGroup } from '../../utilities/eventGroup/EventGroup'; import './DetailsHeader.scss'; const MOUSEDOWN_PRIMARY_BUTTON = 0; // for mouse down event we are using ev.button property, 0 means left button diff --git a/src/utilities/selection/MarqueeSelection.tsx b/src/utilities/selection/MarqueeSelection.tsx index cea3a5e92a0d8..b9a9a66269d6c 100644 --- a/src/utilities/selection/MarqueeSelection.tsx +++ b/src/utilities/selection/MarqueeSelection.tsx @@ -11,8 +11,8 @@ import { } from './MarqueeSelection.Props'; export interface IMarqueeSelectionState { - dragOrigin?: IPoint, - dragRect?: IRectangle + dragOrigin?: IPoint; + dragRect?: IRectangle; } const MIN_DRAG_DISTANCE = 10; diff --git a/src/utilities/selection/Selection.ts b/src/utilities/selection/Selection.ts index 8db76a7780756..24a0836c37438 100644 --- a/src/utilities/selection/Selection.ts +++ b/src/utilities/selection/Selection.ts @@ -7,6 +7,7 @@ export class Selection implements ISelection { private _changeEventSuppressionCount: number; private _items: IObjectWithKey[]; + private _selectedItems: IObjectWithKey[]; private _isAllSelected: boolean; private _exemptedIndices: { [index: string]: boolean }; private _exemptedCount: number; @@ -95,21 +96,17 @@ export class Selection implements ISelection { } public getSelection(): IObjectWithKey[] { - let selectedItems = []; + if (!this._selectedItems) { + this._selectedItems = []; - for (let i = 0; i < this._items.length; i++) { - let item = this._items[i]; - let isExempt = !!this._exemptedIndices[i]; - let key = item ? this.getKey(item, i) : null; - - if ((!key && this._isAllSelected) || - (key && this._isAllSelected && !isExempt) || - (key && !this._isAllSelected && isExempt)) { - selectedItems.push(item); + for (let i = 0; i < this._items.length; i++) { + if (this.isIndexSelected(i)) { + this._selectedItems.push(this._items[i]); + } } } - return selectedItems; + return this._selectedItems; } public getSelectedCount(): number { @@ -224,6 +221,7 @@ export class Selection implements ISelection { private _change() { if (this._changeEventSuppressionCount === 0) { + this._selectedItems = null; EventGroup.raise(this, SELECTION_CHANGE); From bb4d1c09b6ef2a896bc2a7adaf4f15c62606b01e Mon Sep 17 00:00:00 2001 From: David Zearing Date: Thu, 4 Aug 2016 16:46:41 -0700 Subject: [PATCH 06/27] Adding example page, improving props documentation, adding memoization to measures to improve performance. --- src/demo/components/App/AppState.ts | 27 +++--- .../MarqueeSelectionPage.tsx | 36 ++++++++ .../MarqueeSelection.Basic.Example.scss | 32 +++++++ .../MarqueeSelection.Basic.Example.tsx | 65 ++++++++++++++ src/utilities/selection/AutoScroll.ts | 30 ++++--- .../selection/MarqueeSelection.Props.ts | 35 ++++---- src/utilities/selection/MarqueeSelection.tsx | 89 +++++++++++-------- 7 files changed, 240 insertions(+), 74 deletions(-) create mode 100644 src/demo/pages/MarqueeSelectionPage/MarqueeSelectionPage.tsx create mode 100644 src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss create mode 100644 src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx diff --git a/src/demo/components/App/AppState.ts b/src/demo/components/App/AppState.ts index 7b2b1f6798f8c..1beebaa400406 100644 --- a/src/demo/components/App/AppState.ts +++ b/src/demo/components/App/AppState.ts @@ -21,6 +21,7 @@ import { LabelPage } from '../../pages/LabelPage/LabelPage'; import { LayerPage } from '../../pages/LayerPage/LayerPage'; import { LinkPage } from '../../pages/LinkPage/LinkPage'; import { ListPage } from '../../pages/ListPage/ListPage'; +import { MarqueeSelectionPage } from '../../pages/MarqueeSelectionPage/MarqueeSelectionPage'; import { MessageBarPage } from '../../pages/MessageBarPage/MessageBarPage'; import { NavPage } from '../../pages/NavPage/NavPage'; import { OrgChartPage } from '../../pages/OrgChartPage/OrgChartPage'; @@ -86,7 +87,7 @@ export const AppState: IAppState = { component: ChoiceGroupPage, name: 'ChoiceGroup', status: ExampleStatus.beta, - url: '#/examples/ChoiceGroup' + url: '#/examples/choicegroup' }, { component: CommandBarPage, @@ -134,7 +135,7 @@ export const AppState: IAppState = { component: FacepilePage, name: 'Facepile', status: ExampleStatus.started, - url: '#/examples/Facepile' + url: '#/examples/facepile' }, { component: LabelPage, @@ -158,7 +159,7 @@ export const AppState: IAppState = { component: MessageBarPage, name: 'MessageBar', status: ExampleStatus.placeholder, - url: '#/examples/MessageBar' + url: '#/examples/messagebar' }, { component: OrgChartPage, @@ -247,7 +248,7 @@ export const AppState: IAppState = { component: GroupedListPage, name: 'GroupedList', status: ExampleStatus.started, - url: '#examples/GroupedList' + url: '#examples/groupedlist' }, { component: ImagePage, @@ -265,13 +266,13 @@ export const AppState: IAppState = { component: NavPage, name: 'Nav', status: ExampleStatus.started, - url: '#/examples/Nav?mytest=1' + url: '#/examples/nav' }, { component: SliderPage, name: 'Slider', status: ExampleStatus.beta, - url: '#/examples/Slider' + url: '#/examples/slider' } ], name: 'Extended components' @@ -280,21 +281,27 @@ export const AppState: IAppState = { links: [ { component: FocusTrapZonePage, - name: 'Focus Trap zones', + name: 'FocusTrapZone', status: ExampleStatus.beta, url: '#examples/focustrapzone' }, { component: FocusZonePage, - name: 'Focus zones', + name: 'FocusZone', status: ExampleStatus.beta, url: '#examples/focuszone' }, + { + component: MarqueeSelectionPage, + name: 'MarqueeSelection', + status: ExampleStatus.beta, + url: '#examples/marqueeselection' + }, { component: SelectionPage, - name: 'Selection management', + name: 'Selection', status: ExampleStatus.beta, - url: '#examples/selectionManagement' + url: '#examples/selection' }, { component: ThemePage, diff --git a/src/demo/pages/MarqueeSelectionPage/MarqueeSelectionPage.tsx b/src/demo/pages/MarqueeSelectionPage/MarqueeSelectionPage.tsx new file mode 100644 index 0000000000000..5010b49383a38 --- /dev/null +++ b/src/demo/pages/MarqueeSelectionPage/MarqueeSelectionPage.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { ExampleCard } from '../../components/index'; + +import { MarqueeSelectionBasicExample } from './examples/MarqueeSelection.Basic.Example'; + +const MarqueeSelectionBasicExampleCode = require('./examples/MarqueeSelection.Basic.Example.tsx'); + +export class MarqueeSelectionPage extends React.Component { + + public render() { + return ( +
+

MarqueeSelection

+

+ The MarqueeSelection component provides a service which allows the user to drag a rectangle to be drawn around + items to select them. This works in conjunction with a selection object, which can be used to generically store selection state, separate from a component that consumes the state. +

+

+ MarqueeSelection also works in conjunction with the AutoScroll utility to automatically scroll the container when we drag a rectangle within the vicinity of the edges. +

+

+ When a selection rectangle is dragged, we look for elements with the data-selection-index attribute populated. We get these elements' boundingClientRects and compare them with the root's rect to determine selection state. We update the selection state appropriately. +

+

+ In virtualization cases where items that were once selected are dematerialized, we will keep the item in its + previous state until we know definitively if it's on/off. (In other words, this works with List.) +

+

Examples

+ + + +
+ ); + } + +} \ No newline at end of file diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss new file mode 100644 index 0000000000000..16300524fe4c1 --- /dev/null +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss @@ -0,0 +1,32 @@ +@import '../../../../common/common'; + +.ms-MarqueeSelectionBasicExample-photoList { + display: inline-block; + border: 1px solid $ms-color-neutralTertiary; + margin: 0; + padding: 10px; + line-height: 0; + overflow: hidden; +} + +.ms-MarqueeSelectionBasicExample-photoCell { + position: relative; + display: inline-block; + padding: 2px; + box-sizing: border-box; + + &.is-selected { + outline: none; + + &:after { + content: ''; + position: absolute; + right: 4px; + left: 4px; + top: 4px; + bottom: 4px; + border: 1px solid white; + outline: 2px solid $ms-color-themePrimary; + } + } +} \ No newline at end of file diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx new file mode 100644 index 0000000000000..d0b62096e8ba8 --- /dev/null +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx @@ -0,0 +1,65 @@ +/* tslint:disable:no-unused-variable */ +import * as React from 'react'; +/* tslint:enable:no-unused-variable */ + +import { + Image, + Selection, + MarqueeSelection, + css +} from '../../../../index'; +import { createArray } from '../../../../utilities/array'; +import './MarqueeSelection.Basic.Example.scss'; + +const PHOTOS = createArray(250, () => { + const randomWidth = 50 + Math.floor(Math.random() * 150); + + return { + url: `http://placekitten.com/${randomWidth}/100`, + width: randomWidth, + height: 100 + }; +}); + +export class MarqueeSelectionBasicExample extends React.Component<{}, {}> { + private _selection: Selection; + private _isMounted: boolean; + + constructor() { + super(); + + this._selection = new Selection(() => { + if (this._isMounted) { + this.forceUpdate() + } + }); + + this._selection.setItems(PHOTOS); + } + + public componentDidMount() { + this._isMounted = true; + } + + public render() { + return ( + +
    + { PHOTOS.map((photo, index) => ( +
    console.log('clicked') }> + +
    + )) } +
+
+ ); + } + +} diff --git a/src/utilities/selection/AutoScroll.ts b/src/utilities/selection/AutoScroll.ts index 212b60339f510..b9d6f2488e412 100644 --- a/src/utilities/selection/AutoScroll.ts +++ b/src/utilities/selection/AutoScroll.ts @@ -9,11 +9,12 @@ export class AutoScroll { private _events: EventGroup; private _scrollableParent: HTMLElement; private _scrollVelocity: number; - private _intervalId: number; + private _timeoutId: number; constructor(element: HTMLElement) { this._events = new EventGroup(this); this._scrollableParent = findScrollableParent(element); + this._incrementScroll = this._incrementScroll.bind(this); if (this._scrollableParent) { this._events.on(window, 'mousemove', this._onMouseMove, true); @@ -22,7 +23,7 @@ export class AutoScroll { public dispose() { this._events.dispose(); - this._stopInterval(); + this._stopScroll(); } private _onMouseMove(ev: MouseEvent) { @@ -44,24 +45,27 @@ export class AutoScroll { } if (this._scrollVelocity) { - this._startInterval(); + this._startScroll(); } else { - this._stopInterval(); + this._stopScroll(); } } - private _startInterval() { - if (!this._intervalId) { - this._intervalId = setInterval(() => { - this._scrollableParent.scrollTop += Math.round(this._scrollVelocity); - }, SCROLL_ITERATION_DELAY); + private _startScroll() { + if (!this._timeoutId) { + this._incrementScroll(); } } - private _stopInterval() { - if (this._intervalId) { - clearInterval(this._intervalId); - delete this._intervalId; + private _incrementScroll() { + this._scrollableParent.scrollTop += Math.round(this._scrollVelocity); + this._timeoutId = setTimeout(this._incrementScroll, SCROLL_ITERATION_DELAY); + } + + private _stopScroll() { + if (this._timeoutId) { + clearTimeout(this._timeoutId); + delete this._timeoutId; } } } \ No newline at end of file diff --git a/src/utilities/selection/MarqueeSelection.Props.ts b/src/utilities/selection/MarqueeSelection.Props.ts index 8c5659c32f4c1..c892f07e0e0c8 100644 --- a/src/utilities/selection/MarqueeSelection.Props.ts +++ b/src/utilities/selection/MarqueeSelection.Props.ts @@ -2,24 +2,27 @@ import * as React from 'react'; import { ISelection } from './interfaces'; import { MarqueeSelection } from './MarqueeSelection'; -export interface IPoint { - x: number; - y: number; -} +export interface IMarqueeSelectionProps extends React.Props { + /** + * The selection object to interact with when updating selection changes. + */ + selection: ISelection; -export interface IRectangle { - left: number; - top: number; - width: number; - height: number; + /** + * The base element tag name to render the marquee bounding area within. + * @default div + */ + rootTagName?: string; - right?: number; - bottom?: number; -} + /** + * Optional props to mix into the root element. + */ + rootProps?: React.HTMLProps; -export interface IMarqueeSelectionProps extends React.Props { - baseElement?: string; - className?: string; - selection?: ISelection; + /** + * Optional callback that is called, when the mouse down event occurs, in order to determine + * if we should start a marquee selection. If true is returned, we will cancel the mousedown + * event to prevent upstream mousedown handlers from executing. + */ onShouldStartSelection?: (ev: React.MouseEvent) => boolean; } diff --git a/src/utilities/selection/MarqueeSelection.tsx b/src/utilities/selection/MarqueeSelection.tsx index 82ffcffd38816..f0fb5fe35d365 100644 --- a/src/utilities/selection/MarqueeSelection.tsx +++ b/src/utilities/selection/MarqueeSelection.tsx @@ -1,25 +1,42 @@ import * as React from 'react'; +import { AutoScroll } from './AutoScroll'; import { BaseComponent } from '../../common/BaseComponent'; +import { assign } from '../../utilities/object'; import { css } from '../../utilities/css'; import { findScrollableParent } from '../scrollUtilities'; -import { AutoScroll } from './AutoScroll'; -import './MarqueeSelection.scss'; import { - IRectangle, - IPoint, IMarqueeSelectionProps } from './MarqueeSelection.Props'; +import './MarqueeSelection.scss'; + +export interface IPoint { + x: number; + y: number; +} + +export interface IRectangle { + left: number; + top: number; + width: number; + height: number; + + right?: number; + bottom?: number; +} export interface IMarqueeSelectionState { dragOrigin?: IPoint; dragRect?: IRectangle; } -const MIN_DRAG_DISTANCE = 10; +// We want to make the marquee selection start when the user drags a minimum distance. Otherwise we'd start +// the drag even if they just click an item without moving. +const MIN_DRAG_DISTANCE = 5; export class MarqueeSelection extends BaseComponent { public static defaultProps = { - baseElement: 'div' + rootTagName: 'div', + rootProps: {} }; public refs: { @@ -32,6 +49,7 @@ export class MarqueeSelection extends BaseComponent dragRect.top && - itemRect.left < dragRect.right && + itemRect.left < (dragRect.left + dragRect.width) && itemRect.right > dragRect.left ) { this._selectedIndicies[index] = true; From 089fd22e7d1f40b573b02914e27a34e8eedcc93d Mon Sep 17 00:00:00 2001 From: David Zearing Date: Thu, 4 Aug 2016 17:09:47 -0700 Subject: [PATCH 07/27] More performance improvements. --- .../examples/MarqueeSelection.Basic.Example.tsx | 3 ++- src/utilities/selection/MarqueeSelection.tsx | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx index d0b62096e8ba8..f8c365d22956c 100644 --- a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx @@ -30,7 +30,7 @@ export class MarqueeSelectionBasicExample extends React.Component<{}, {}> { this._selection = new Selection(() => { if (this._isMounted) { - this.forceUpdate() + this.forceUpdate(); } }); @@ -44,6 +44,7 @@ export class MarqueeSelectionBasicExample extends React.Component<{}, {}> { public render() { return ( +

Drag a rectangle around the items below to select them:

    { PHOTOS.map((photo, index) => (
    Date: Thu, 4 Aug 2016 17:34:53 -0700 Subject: [PATCH 08/27] Moving files to a more logical location. --- src/MarqueeSelection.ts | 3 ++ src/common/BaseComponent.ts | 5 ++ .../MarqueeSelection.Props.ts | 2 +- .../MarqueeSelection}/MarqueeSelection.scss | 0 .../MarqueeSelection}/MarqueeSelection.tsx | 10 +++- .../examples/Selection.Basic.Example.tsx | 53 +++++++++---------- .../{selection => AutoScroll}/AutoScroll.ts | 6 +++ src/utilities/selection/index.ts | 1 - 8 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 src/MarqueeSelection.ts rename src/{utilities/selection => components/MarqueeSelection}/MarqueeSelection.Props.ts (92%) rename src/{utilities/selection => components/MarqueeSelection}/MarqueeSelection.scss (100%) rename src/{utilities/selection => components/MarqueeSelection}/MarqueeSelection.tsx (92%) rename src/utilities/{selection => AutoScroll}/AutoScroll.ts (87%) diff --git a/src/MarqueeSelection.ts b/src/MarqueeSelection.ts new file mode 100644 index 0000000000000..75b9d1b0a56e8 --- /dev/null +++ b/src/MarqueeSelection.ts @@ -0,0 +1,3 @@ +export * from './components/MarqueeSelection/MarqueeSelection'; +export * from './components/MarqueeSelection/MarqueeSelection.Props'; +export * from './utilities/selection/index'; diff --git a/src/common/BaseComponent.ts b/src/common/BaseComponent.ts index 15824d361d8c4..043c8588d6a2d 100644 --- a/src/common/BaseComponent.ts +++ b/src/common/BaseComponent.ts @@ -3,6 +3,11 @@ import { Async } from '../utilities/Async/Async'; import { EventGroup } from '../utilities/eventGroup/EventGroup'; import { IDisposable } from './IDisposable'; +// Ensure that the HTML element has a dir specified. This helps to ensure RTL/LTR macros in css for all components will work. +if (document && document.documentElement && !document.documentElement.getAttribute('dir')) { + document.documentElement.setAttribute('dir', 'ltr'); +} + export class BaseComponent extends React.Component { /** * External consumers should override BaseComponent.onError to hook into error messages that occur from diff --git a/src/utilities/selection/MarqueeSelection.Props.ts b/src/components/MarqueeSelection/MarqueeSelection.Props.ts similarity index 92% rename from src/utilities/selection/MarqueeSelection.Props.ts rename to src/components/MarqueeSelection/MarqueeSelection.Props.ts index c892f07e0e0c8..02505f9fa7e4f 100644 --- a/src/utilities/selection/MarqueeSelection.Props.ts +++ b/src/components/MarqueeSelection/MarqueeSelection.Props.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ISelection } from './interfaces'; +import { ISelection } from '../../utilities/selection/interfaces'; import { MarqueeSelection } from './MarqueeSelection'; export interface IMarqueeSelectionProps extends React.Props { diff --git a/src/utilities/selection/MarqueeSelection.scss b/src/components/MarqueeSelection/MarqueeSelection.scss similarity index 100% rename from src/utilities/selection/MarqueeSelection.scss rename to src/components/MarqueeSelection/MarqueeSelection.scss diff --git a/src/utilities/selection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx similarity index 92% rename from src/utilities/selection/MarqueeSelection.tsx rename to src/components/MarqueeSelection/MarqueeSelection.tsx index 8cca70406cbf2..cc6dc1772f894 100644 --- a/src/utilities/selection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { AutoScroll } from './AutoScroll'; +import { AutoScroll } from '../../utilities/AutoScroll/AutoScroll'; import { BaseComponent } from '../../common/BaseComponent'; import { assign } from '../../utilities/object'; import { css } from '../../utilities/css'; -import { findScrollableParent } from '../scrollUtilities'; +import { findScrollableParent } from '../../utilities/scrollUtilities'; import { IMarqueeSelectionProps } from './MarqueeSelection.Props'; @@ -33,6 +33,12 @@ export interface IMarqueeSelectionState { // the drag even if they just click an item without moving. const MIN_DRAG_DISTANCE = 5; +/** + * MarqueeSelection component abstracts managing a draggable rectangle which sets items selected/not selected. + * Elements which have data-selectable-index attributes are queried and measured once to determine if they + * fall within the bounds of the rectangle. The measure is memoized during the drag as a performance optimization + * so if the items change sizes while dragging, that could cause incorrect results. + */ export class MarqueeSelection extends BaseComponent { public static defaultProps = { rootTagName: 'div', diff --git a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx index b8419fdfaffbf..fe7bf9f61a008 100644 --- a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx +++ b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx @@ -6,9 +6,8 @@ import { ISelection, Selection, SelectionMode, - SelectionZone, - MarqueeSelection - } from '../../../../utilities/selection/index'; + SelectionZone +} from '../../../../utilities/selection/index'; import { createListItems } from '../../../utilities/data'; import './Selection.Example.scss'; @@ -58,23 +57,21 @@ export class SelectionBasicExample extends React.Component -
    - - - { items.map((item, index) => ( - - )) } - -
    -
    +
    + + + { items.map((item, index) => ( + + )) } + +
    ); } @@ -150,14 +147,14 @@ export class SelectionItemExample extends React.Component - { (selectionMode !== SelectionMode.none) && ( - - ) } - - { item.name } - + { (selectionMode !== SelectionMode.none) && ( + + ) } + + { item.name } +
    ); } diff --git a/src/utilities/selection/AutoScroll.ts b/src/utilities/AutoScroll/AutoScroll.ts similarity index 87% rename from src/utilities/selection/AutoScroll.ts rename to src/utilities/AutoScroll/AutoScroll.ts index b9d6f2488e412..8f513d54999eb 100644 --- a/src/utilities/selection/AutoScroll.ts +++ b/src/utilities/AutoScroll/AutoScroll.ts @@ -5,6 +5,12 @@ const SCROLL_ITERATION_DELAY = 30; const SCROLL_GUTTER_HEIGHT = 100; const MAX_SCROLL_VELOCITY = 20; +/** + * AutoScroll simply hooks up mouse events given a parent element, and scrolls the container + * up/down depending on how close the mouse is to the top/bottom of the container. + * + * Once you don't want autoscroll any more, just dispose the helper and it will unhook events. + */ export class AutoScroll { private _events: EventGroup; private _scrollableParent: HTMLElement; diff --git a/src/utilities/selection/index.ts b/src/utilities/selection/index.ts index 2f2403065cffd..c3ca5de5938f9 100644 --- a/src/utilities/selection/index.ts +++ b/src/utilities/selection/index.ts @@ -2,4 +2,3 @@ export * from './interfaces'; export * from './Selection'; export * from './SelectionLayout'; export * from './SelectionZone'; -export * from './MarqueeSelection'; From 3c3b43d7e8b6ea77aa135574128335ea66d9f254 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Thu, 4 Aug 2016 17:42:12 -0700 Subject: [PATCH 09/27] Missing an index change. --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 00b5ed7ca280c..4cf9039e496cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export * from './Layer'; export * from './Link'; export * from './List'; export * from './MessageBar'; +export * from './MarqueeSelection'; export * from './Nav'; export * from './OrgChart'; export * from './Overlay'; From 66ef16dc984def634b2b4c780268e488e0c2a401 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Thu, 4 Aug 2016 18:06:39 -0700 Subject: [PATCH 10/27] Adding more best practices content. --- ghdocs/BESTPRACTICES.md | 69 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/ghdocs/BESTPRACTICES.md b/ghdocs/BESTPRACTICES.md index 4023121785c15..da441c5a701f9 100644 --- a/ghdocs/BESTPRACTICES.md +++ b/ghdocs/BESTPRACTICES.md @@ -1,8 +1,69 @@ +# Component design + +## Build many smaller components and composite them together. + +Often we want to build something complex, like a CommandBar. We see a picture of what we want and we build one giant component that does this. Then we find other scenarios that have overlaps. The CommandBar contains a SearchBox, a set of left side collapsable links and right side links. We may find we have other cases that just want a set of links, without the extra overhead. We may also find cases where we just want a single command bar item with a dropdown menu. + +This is an example where instead of building a single large component, we should build more atomic building blocks that are puzzled together. While it may be more effort to think in smaller blocks, it makes the code easier to reuse and often simpler to maintain. + +## Use a .Props.ts file to extract out the public contracts that should be supported and documented. + +A props file contains all of the interface contracts that the user should know about to use the component. It is the "contract" for the component. When we evaluate semversioning, we look through the changes at Props files to determine if the change is a major, minor, or patch. + +The props files are also auto documented. All JSDoc comments will be extracted and shown on the demo site for documentation. + +When your component exposes public methods/properties, define an interface for the component in the props file and implement the interface. The auto documentation will interpret the I{Component} interface as the class contract and show it in the documentation as the class definition. + +```typescript +interface IButton { + /** + * Sets focus to the button element. + */ + focus(): void; +} +``` + +## Extend from BaseComponent instead of React.Component in most cases. + +In the common folder, there exists a BaseComponent class. For simple components, it may be unnecessary to use. + +If you extend this, you get a few useful utilities: + +_events: An instance of the EventGroup, scoped to the component. It will auto dispose on component unmounting so that you don't forget. + +_async: A collection of utilities for performing async operations, scoped to the component. This includes setTimeout/setInterval helpers as well as utilities for generating throttled/debounced wrappers. Again, anything you use here will be automatically cleaned up on unmounting. + +_disposables: An array of IDisposable instances. If you have things you want disposed, you can push them into this. + +autoBindCallbacks: A helper method that will automatically bind _on methods, to simplify the manual binding of event callbacks. + +Another interesting thing is that when exceptions occur within React's methods, things tend to silently fail. With the BaseComponent, we +make all methods "safe" meaning that if an exception occurs, we send the exception to a global callback which can be hooked up to a telemetry post. By default however, we forward the exception to console.error with the exact class/method that threw the exception so that there is an obvious hint what went wrong. + +There are some cases where it may be overkill to subclass from this; a simple Button wrapper for example really doesn't need to be more than a simple stateless component and doesn't need extra imports, which would result in making Button's dependency graph heavier. Use your best judgement. + +## Use React eventing, unless you need to use native. + +Be aware that React eventing and DOM eventing are two different systems. They do not play well with each other. DOM event handlers will always fire BEFORE React events, regardless of the DOM structure. This can introduce unexpected bugs in code that mixes both React and native DOM eventing. + +Unfortunately there are plenty of scenarios where we must mix the two systems together; for example, you may need to listen for application-wide clicks that bubble up to window in order to implement a light-dismiss behavior. Or perhaps you need to listen for window resizes. Or maybe you need to observe scroll events so that you can hide/show something. + +We use the EventGroup object for abstracting native eventing. It is simple to use; there is an "on" method and an "off" method that wrap calling addEventListener in modern browsers (or attachEvent in legacy IE.) Again if you're using the BaseComponent, it is already available to you via the _events property. + +## Root elements should have a component class name. + +Every component's root element should have a ms-Component class name. Additinally the user should be able to provide their own className via prop that should be added to the class list of the root element. + +If specific component elements need special classnames injected, add more classNames to props. + +A component's SCSS file should ONLY include files applicable to the component, and should not define styles for any other component. + # Class name guidelines TODO: include our class name guidelines. Example: + ms-Component-area--flags # Style guidelines @@ -35,14 +96,6 @@ Additionaly try to have symetrical paddings rather than using padding-right or l E.g. using ms-font-s classname in a component is forbidden. It makes overriding CSS rules really painful. Instead, use @include ms-font-m; -## Root elements should have a component class name. - -Every component's root element should have a ms-Component class name. Additinally the user should be able to provide their own className via prop that should be added to the class list of the root element. - -If specific component elements need special classnames injected, add more classNames to props. - -A component's SCSS file should ONLY include files applicable to the component, and should not define styles for any other component. - # Example page guidelines Examples should follow a naming convention: Component.Desc.Example.ts From 6729ebdb1bc3948364ff5a86a4791151d648cfea Mon Sep 17 00:00:00 2001 From: David Zearing Date: Thu, 4 Aug 2016 18:07:16 -0700 Subject: [PATCH 11/27] Updating documentation. --- src/common/BaseComponent.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/common/BaseComponent.ts b/src/common/BaseComponent.ts index 043c8588d6a2d..ffbe2b89edce6 100644 --- a/src/common/BaseComponent.ts +++ b/src/common/BaseComponent.ts @@ -52,9 +52,11 @@ export class BaseComponent extends React.Component { return (results && results.length > 1) ? results[1] : ''; } - /** Gives subclasses a way to automatically bind methods that are prefixed with "_on". */ - protected autoBindCallbacks(prototype: Object) { - for (let methodName in prototype) { + /** + * Gives the class constructor, will iterate through prototype methods prefixed with "_on" and bind them to "this". + * Example: in your constructor, you'd have: this.autoBindCallbacks(MyComponent); */ + protected autoBindCallbacks(object: Function) { + for (let methodName in object.prototype) { if (methodName.indexOf('_on') === 0) { this[methodName] = this[methodName].bind(this); } From 9d12f132e7d3e0070d992c10236eeccf82689539 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Thu, 4 Aug 2016 19:07:31 -0700 Subject: [PATCH 12/27] Removing unnecessary call. --- src/components/MarqueeSelection/MarqueeSelection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx index cc6dc1772f894..adcf936f7c781 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -69,7 +69,7 @@ export class MarqueeSelection extends BaseComponent Date: Thu, 4 Aug 2016 19:08:00 -0700 Subject: [PATCH 13/27] Removing dir from html. --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 4203ebda5e05f..ed23055b9405d 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + From 42954705787445e4c2fec67697e4008b3279ced1 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Sat, 6 Aug 2016 16:11:12 -0700 Subject: [PATCH 14/27] Removing an unnecessary measure from autoscroll. --- src/utilities/AutoScroll/AutoScroll.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/utilities/AutoScroll/AutoScroll.ts b/src/utilities/AutoScroll/AutoScroll.ts index 8f513d54999eb..2089b07f31269 100644 --- a/src/utilities/AutoScroll/AutoScroll.ts +++ b/src/utilities/AutoScroll/AutoScroll.ts @@ -1,9 +1,9 @@ import { EventGroup } from '../eventGroup/EventGroup'; import { findScrollableParent } from '../scrollUtilities'; -const SCROLL_ITERATION_DELAY = 30; +const SCROLL_ITERATION_DELAY = 16; const SCROLL_GUTTER_HEIGHT = 100; -const MAX_SCROLL_VELOCITY = 20; +const MAX_SCROLL_VELOCITY = 15; /** * AutoScroll simply hooks up mouse events given a parent element, and scrolls the container @@ -14,6 +14,7 @@ const MAX_SCROLL_VELOCITY = 20; export class AutoScroll { private _events: EventGroup; private _scrollableParent: HTMLElement; + private _scrollRect: ClientRect; private _scrollVelocity: number; private _timeoutId: number; @@ -21,6 +22,7 @@ export class AutoScroll { this._events = new EventGroup(this); this._scrollableParent = findScrollableParent(element); this._incrementScroll = this._incrementScroll.bind(this); + this._scrollRect = this._scrollableParent.getBoundingClientRect(); if (this._scrollableParent) { this._events.on(window, 'mousemove', this._onMouseMove, true); @@ -33,13 +35,13 @@ export class AutoScroll { } private _onMouseMove(ev: MouseEvent) { - let scrollRect = this._scrollableParent.getBoundingClientRect(); - let scrollClientBottom = scrollRect.top + scrollRect.height - SCROLL_GUTTER_HEIGHT; + let scrollRectTop = this._scrollRect.top; + let scrollClientBottom = scrollRectTop + this._scrollRect.height - SCROLL_GUTTER_HEIGHT; - if (ev.clientY < (scrollRect.top + SCROLL_GUTTER_HEIGHT)) { + if (ev.clientY < (scrollRectTop + SCROLL_GUTTER_HEIGHT)) { this._scrollVelocity = Math.max( -MAX_SCROLL_VELOCITY, - -MAX_SCROLL_VELOCITY * ((SCROLL_GUTTER_HEIGHT - (ev.clientY - scrollRect.top)) / SCROLL_GUTTER_HEIGHT + -MAX_SCROLL_VELOCITY * ((SCROLL_GUTTER_HEIGHT - (ev.clientY - scrollRectTop)) / SCROLL_GUTTER_HEIGHT )); } else if (ev.clientY > scrollClientBottom) { this._scrollVelocity = Math.min( From 0c7a8a4fb677848b922cef4097b6539734a96193 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Sun, 7 Aug 2016 21:24:46 -0700 Subject: [PATCH 15/27] Updating basic details list example to use marquee selection. --- src/demo/pages/DetailsListPage/DetailsListPage.tsx | 2 +- .../examples/DetailsList.Basic.Example.tsx | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/demo/pages/DetailsListPage/DetailsListPage.tsx b/src/demo/pages/DetailsListPage/DetailsListPage.tsx index 890cebf2e90ba..df71e84ca3fbd 100644 --- a/src/demo/pages/DetailsListPage/DetailsListPage.tsx +++ b/src/demo/pages/DetailsListPage/DetailsListPage.tsx @@ -29,7 +29,7 @@ export class DetailsListPage extends React.Component { and provides a sortable, filterable, justified table for rendering large sets of items. This component replaces the Table Component.

    Examples

    - + diff --git a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx index 79eb9c9800697..275139543641c 100644 --- a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx +++ b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx @@ -3,17 +3,23 @@ import * as React from 'react'; /* tslint:enable:no-unused-variable */ import { DetailsList, - TextField + TextField, + Selection, + MarqueeSelection } from '../../../../index'; import { createListItems } from '../../../utilities/data'; let _items: any[]; export class DetailsListBasicExample extends React.Component { + private _selection: Selection; + constructor() { super(); _items = _items || createListItems(500); + this._selection = new Selection(); + this.state = { filterText: '' }; } @@ -27,7 +33,9 @@ export class DetailsListBasicExample extends React.Component { label='Filter by name:' onChanged={ text => this.setState({ filterText: text }) } /> - + + +
); } From fb61f66f22b619cf5f824cb9cb7b955bf7f7436c Mon Sep 17 00:00:00 2001 From: David Zearing Date: Sun, 7 Aug 2016 21:49:21 -0700 Subject: [PATCH 16/27] With scrolltop fix (#3) * With scrolltop fix. * addressing lint issues --- .../MarqueeSelection/MarqueeSelection.tsx | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx index adcf936f7c781..5c3d987eb3dd3 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -19,7 +19,6 @@ export interface IRectangle { top: number; width: number; height: number; - right?: number; bottom?: number; } @@ -56,6 +55,8 @@ export class MarqueeSelection extends BaseComponent MIN_DRAG_DISTANCE) { - // We need to constain the current point to the rootRect boundaries. + // We need to constrain the current point to the rootRect boundaries. let constrainedPoint = { - x: Math.max(0, Math.min(this._rootRect.width, this._lastMouseEvent.clientX - this._rootRect.left)), - y: Math.max(0, Math.min(this._rootRect.height, this._lastMouseEvent.clientY - this._rootRect.top)) + x: Math.max(0, Math.min(rootRect.width, this._lastMouseEvent.clientX - rootRect.left)), + y: Math.max(0, Math.min(rootRect.height, this._lastMouseEvent.clientY - rootRect.top)) }; this.setState({ @@ -184,7 +193,7 @@ export class MarqueeSelection extends BaseComponent 0 && itemRect.height > 0) { + this._itemRectCache[index] = itemRect; + } } if ( From 7ade6e9b331c2ac6eb89517ee0607f69b10fb3ef Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 01:12:39 -0700 Subject: [PATCH 17/27] Improving the example by removing the images. --- .../MarqueeSelection.Basic.Example.scss | 20 ++++++++++--------- .../MarqueeSelection.Basic.Example.tsx | 7 ++++--- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss index 16300524fe4c1..ee4d8288abb59 100644 --- a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss @@ -5,28 +5,30 @@ border: 1px solid $ms-color-neutralTertiary; margin: 0; padding: 10px; - line-height: 0; overflow: hidden; } .ms-MarqueeSelectionBasicExample-photoCell { position: relative; display: inline-block; - padding: 2px; + margin: 2px; box-sizing: border-box; + background: $ms-color-neutralLighter; + line-height: 100px; + vertical-align: middle; + text-align: center; &.is-selected { - outline: none; + background: $ms-color-themeLighter; &:after { content: ''; position: absolute; - right: 4px; - left: 4px; - top: 4px; - bottom: 4px; - border: 1px solid white; - outline: 2px solid $ms-color-themePrimary; + right: 0px; + left: 0px; + top: 0px; + bottom: 0px; + border: 1px solid $ms-color-themePrimary; } } } \ No newline at end of file diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx index f8c365d22956c..31749c5fd9a4a 100644 --- a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx @@ -30,7 +30,7 @@ export class MarqueeSelectionBasicExample extends React.Component<{}, {}> { this._selection = new Selection(() => { if (this._isMounted) { - this.forceUpdate(); + this.setState({}); } }); @@ -54,8 +54,9 @@ export class MarqueeSelectionBasicExample extends React.Component<{}, {}> { }) } data-is-focusable={ true } data-selection-index={ index } - onClick={ () => console.log('clicked') }> - + onClick={ () => console.log('clicked') } + style={ { width: photo.width, height: photo.height } }> + { index }
)) } From bd5777df9b57ac6712a9369b86cc5ae4b22871bc Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 01:13:14 -0700 Subject: [PATCH 18/27] Removing the scroll monitoring and css tweaking from Fabric component, due to performance. --- src/components/Fabric/Fabric.tsx | 33 +++----------------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/src/components/Fabric/Fabric.tsx b/src/components/Fabric/Fabric.tsx index 5d33141afceb2..4dc64bf68d992 100644 --- a/src/components/Fabric/Fabric.tsx +++ b/src/components/Fabric/Fabric.tsx @@ -19,7 +19,6 @@ const STATIONARY_DETECTION_DELAY = 100; export interface IFabricState { isFocusVisible?: boolean; - isStationary?: boolean; } export class Fabric extends React.Component, IFabricState> { @@ -29,37 +28,30 @@ export class Fabric extends React.Component, IFabricStat }; private _events: EventGroup; - private _scrollTimerId: number; constructor() { super(); this.state = { - isFocusVisible: false, - isStationary: true + isFocusVisible: false }; this._events = new EventGroup(this); - this._onScrollEnd = this._onScrollEnd.bind(this); } public componentDidMount() { this._events.on(document.body, 'mousedown', this._onMouseDown, true); this._events.on(document.body, 'keydown', this._onKeyDown, true); - this._events.on(window, 'scroll', this._onScroll, true); } public componentWillUnmount() { this._events.dispose(); - clearTimeout(this._scrollTimerId); } public render() { - const { isFocusVisible, isStationary } = this.state; + const { isFocusVisible } = this.state; const rootClass = css('ms-Fabric ms-font-m', this.props.className, { - 'is-focusVisible': isFocusVisible, - 'is-stationary': isStationary, - 'is-scrolling': !isStationary + 'is-focusVisible': isFocusVisible }); return ( @@ -82,23 +74,4 @@ export class Fabric extends React.Component, IFabricStat }); } } - - private _onScroll() { - let { isStationary } = this.state; - - clearTimeout(this._scrollTimerId); - if (isStationary) { - this.setState({ - isStationary: false - }); - } - - this._scrollTimerId = setTimeout(this._onScrollEnd, STATIONARY_DETECTION_DELAY); - } - - private _onScrollEnd() { - this.setState({ - isStationary: true - }); - } } From dca18b5d843a300e0dd662be78fd438737f8efbe Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 01:13:40 -0700 Subject: [PATCH 19/27] Fixing issues related to safari support. --- .../MarqueeSelection/MarqueeSelection.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx index 5c3d987eb3dd3..0e727dd7589f9 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -110,7 +110,7 @@ export class MarqueeSelection extends BaseComponent MIN_DRAG_DISTANCE) { @@ -157,17 +157,21 @@ export class MarqueeSelection extends BaseComponent this._asyncEvaluateSelection()); + }; + + this.setState({ dragRect }); + this._evaluateSelection(dragRect); } } + ev.stopPropagation(); + ev.preventDefault(); + return false; } @@ -190,9 +194,8 @@ export class MarqueeSelection extends BaseComponent Date: Mon, 8 Aug 2016 01:14:05 -0700 Subject: [PATCH 20/27] Minor improvement to EventGroup. --- src/utilities/eventGroup/EventGroup.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utilities/eventGroup/EventGroup.ts b/src/utilities/eventGroup/EventGroup.ts index 38ec3aaf6b901..5de8ed1e44c38 100644 --- a/src/utilities/eventGroup/EventGroup.ts +++ b/src/utilities/eventGroup/EventGroup.ts @@ -179,10 +179,17 @@ export class EventGroup { let result; try { result = callback.apply(parent, args); - if (result === false && args[0] && args[0].preventDefault) { + if (result === false && args[0]) { let e = args[0]; - e.preventDefault(); + if (e.preventDefault) { + e.preventDefault(); + } + + if (e.stopPropagation) { + e.stopPropagation(); + } + e.cancelBubble = true; } } catch (e) { From 59ab874b89e584fbf94cfc2f50d245ce377e6b07 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 01:16:38 -0700 Subject: [PATCH 21/27] Lint fixes. --- src/components/Fabric/Fabric.tsx | 2 -- .../examples/MarqueeSelection.Basic.Example.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/src/components/Fabric/Fabric.tsx b/src/components/Fabric/Fabric.tsx index 4dc64bf68d992..8c44e62e438e1 100644 --- a/src/components/Fabric/Fabric.tsx +++ b/src/components/Fabric/Fabric.tsx @@ -15,8 +15,6 @@ const DIRECTIONAL_KEY_CODES = [ KeyCodes.pageDown ]; -const STATIONARY_DETECTION_DELAY = 100; - export interface IFabricState { isFocusVisible?: boolean; } diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx index 31749c5fd9a4a..831b75bd4f5f0 100644 --- a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; /* tslint:enable:no-unused-variable */ import { - Image, Selection, MarqueeSelection, css From 4079b678379cea4dee3eaf13633d09494c47a171 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 10:08:47 -0700 Subject: [PATCH 22/27] Updates for PR comments. --- ghdocs/BESTPRACTICES.md | 2 - src/common/BaseComponent.ts | 11 --- src/common/IPoint.ts | 4 + src/common/IRectangle.ts | 8 ++ .../MarqueeSelection.Props.ts | 17 ++-- .../MarqueeSelection/MarqueeSelection.tsx | 94 +++++++------------ .../MarqueeSelection.Basic.Example.tsx | 19 +++- src/utilities/math.ts | 7 ++ 8 files changed, 80 insertions(+), 82 deletions(-) create mode 100644 src/common/IPoint.ts create mode 100644 src/common/IRectangle.ts create mode 100644 src/utilities/math.ts diff --git a/ghdocs/BESTPRACTICES.md b/ghdocs/BESTPRACTICES.md index da441c5a701f9..130a90ef9c836 100644 --- a/ghdocs/BESTPRACTICES.md +++ b/ghdocs/BESTPRACTICES.md @@ -35,8 +35,6 @@ _async: A collection of utilities for performing async operations, scoped to the _disposables: An array of IDisposable instances. If you have things you want disposed, you can push them into this. -autoBindCallbacks: A helper method that will automatically bind _on methods, to simplify the manual binding of event callbacks. - Another interesting thing is that when exceptions occur within React's methods, things tend to silently fail. With the BaseComponent, we make all methods "safe" meaning that if an exception occurs, we send the exception to a global callback which can be hooked up to a telemetry post. By default however, we forward the exception to console.error with the exact class/method that threw the exception so that there is an obvious hint what went wrong. diff --git a/src/common/BaseComponent.ts b/src/common/BaseComponent.ts index ffbe2b89edce6..565f952f5c4ca 100644 --- a/src/common/BaseComponent.ts +++ b/src/common/BaseComponent.ts @@ -52,17 +52,6 @@ export class BaseComponent extends React.Component { return (results && results.length > 1) ? results[1] : ''; } - /** - * Gives the class constructor, will iterate through prototype methods prefixed with "_on" and bind them to "this". - * Example: in your constructor, you'd have: this.autoBindCallbacks(MyComponent); */ - protected autoBindCallbacks(object: Function) { - for (let methodName in object.prototype) { - if (methodName.indexOf('_on') === 0) { - this[methodName] = this[methodName].bind(this); - } - } - } - /** Allows subclasses to push things to this._disposables to be auto disposed. */ protected get _disposables(): IDisposable[] { if (!this.__disposables) { diff --git a/src/common/IPoint.ts b/src/common/IPoint.ts new file mode 100644 index 0000000000000..79f682edcb744 --- /dev/null +++ b/src/common/IPoint.ts @@ -0,0 +1,4 @@ +export interface IPoint { + x: number; + y: number; +} diff --git a/src/common/IRectangle.ts b/src/common/IRectangle.ts new file mode 100644 index 0000000000000..b13b19a8b303f --- /dev/null +++ b/src/common/IRectangle.ts @@ -0,0 +1,8 @@ +export interface IRectangle { + left: number; + top: number; + width: number; + height: number; + right?: number; + bottom?: number; +} diff --git a/src/components/MarqueeSelection/MarqueeSelection.Props.ts b/src/components/MarqueeSelection/MarqueeSelection.Props.ts index 02505f9fa7e4f..bb292dc4ceb2d 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.Props.ts +++ b/src/components/MarqueeSelection/MarqueeSelection.Props.ts @@ -9,13 +9,7 @@ export interface IMarqueeSelectionProps extends React.Props { selection: ISelection; /** - * The base element tag name to render the marquee bounding area within. - * @default div - */ - rootTagName?: string; - - /** - * Optional props to mix into the root element. + * Optional props to mix into the root DIV element. */ rootProps?: React.HTMLProps; @@ -25,4 +19,13 @@ export interface IMarqueeSelectionProps extends React.Props { * event to prevent upstream mousedown handlers from executing. */ onShouldStartSelection?: (ev: React.MouseEvent) => boolean; + + /** + * Optional flag to control the enabled state of marquee selection. This allows you to render + * it and have events all ready to go, but conditionally disable it. That way transitioning + * between enabled/disabled generate no difference in the DOM. + * @default true + */ + isEnabled?: boolean; + } diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx index 0e727dd7589f9..6355ecf5f2461 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -1,27 +1,15 @@ import * as React from 'react'; import { AutoScroll } from '../../utilities/AutoScroll/AutoScroll'; import { BaseComponent } from '../../common/BaseComponent'; +import { IMarqueeSelectionProps } from './MarqueeSelection.Props'; +import { IPoint } from '../../common/IPoint'; +import { IRectangle } from '../../common/IRectangle'; import { assign } from '../../utilities/object'; import { css } from '../../utilities/css'; import { findScrollableParent } from '../../utilities/scrollUtilities'; -import { - IMarqueeSelectionProps -} from './MarqueeSelection.Props'; -import './MarqueeSelection.scss'; - -export interface IPoint { - x: number; - y: number; -} +import { getDistanceBetweenPoints } from '../../utilities/math'; -export interface IRectangle { - left: number; - top: number; - width: number; - height: number; - right?: number; - bottom?: number; -} +import './MarqueeSelection.scss'; export interface IMarqueeSelectionState { dragOrigin?: IPoint; @@ -41,7 +29,8 @@ const MIN_DRAG_DISTANCE = 5; export class MarqueeSelection extends BaseComponent { public static defaultProps = { rootTagName: 'div', - rootProps: {} + rootProps: {}, + isEnabled: true }; public refs: { @@ -52,7 +41,7 @@ export class MarqueeSelection extends BaseComponent - ); - selectionBox = ( -
-
-
- ); - } - return React.createElement( - rootTagName, - assign({}, rootProps, { - className: css('ms-MarqueeSelection', rootProps.className), - ref: 'root', - onMouseDown: this._onMouseDown - }), - children, - dragMask, - selectionBox + return ( +
+ { children } + { dragRect && (
) } + { dragRect && ( +
+
+
+ ) } +
); } private _onMouseDown(ev: React.MouseEvent) { - let { onShouldStartSelection } = this.props; + let { isEnabled, onShouldStartSelection } = this.props; - if (!onShouldStartSelection || onShouldStartSelection(ev)) { + if (isEnabled && (!onShouldStartSelection || onShouldStartSelection(ev))) { let scrollableParent = findScrollableParent(this.refs.root); if (scrollableParent && ev.button === 0) { @@ -150,7 +131,7 @@ export class MarqueeSelection extends BaseComponent MIN_DRAG_DISTANCE) { + if (this.state.dragRect || getDistanceBetweenPoints(this._dragOrigin, currentPoint) > MIN_DRAG_DISTANCE) { // We need to constrain the current point to the rootRect boundaries. let constrainedPoint = { x: Math.max(0, Math.min(rootRect.width, this._lastMouseEvent.clientX - rootRect.left)), @@ -158,11 +139,11 @@ export class MarqueeSelection extends BaseComponent { }; }); -export class MarqueeSelectionBasicExample extends React.Component<{}, {}> { +export interface IMarqueeSelectionBasicExampleState { + isMarqueeEnabled: boolean; +} + +export class MarqueeSelectionBasicExample extends React.Component<{}, IMarqueeSelectionBasicExampleState> { private _selection: Selection; private _isMounted: boolean; constructor() { super(); + this.state = { + isMarqueeEnabled: true + }; + this._selection = new Selection(() => { if (this._isMounted) { - this.setState({}); + this.forceUpdate(); } }); @@ -42,7 +51,11 @@ export class MarqueeSelectionBasicExample extends React.Component<{}, {}> { public render() { return ( - + + this.setState({ isMarqueeEnabled }) } />

Drag a rectangle around the items below to select them:

    { PHOTOS.map((photo, index) => ( diff --git a/src/utilities/math.ts b/src/utilities/math.ts new file mode 100644 index 0000000000000..b049c0585e286 --- /dev/null +++ b/src/utilities/math.ts @@ -0,0 +1,7 @@ +import { IPoint } from '../common/IPoint'; + +export function getDistanceBetweenPoints(point1: IPoint, point2: IPoint): number { + let distance = Math.sqrt(Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2)); + + return distance; +} \ No newline at end of file From 4a08c31a2a7a908e5b54a398305007c4c53087d8 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 10:47:45 -0700 Subject: [PATCH 23/27] Fixing hovers. --- src/components/DetailsList/DetailsRow.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DetailsList/DetailsRow.scss b/src/components/DetailsList/DetailsRow.scss index d04b8887402ac..9a3c0e4f58256 100644 --- a/src/components/DetailsList/DetailsRow.scss +++ b/src/components/DetailsList/DetailsRow.scss @@ -31,11 +31,11 @@ $unselectedHoverColor: $ms-color-neutralLighter; } } -.ms-Fabric.is-stationary .ms-DetailsRow:hover { +.ms-DetailsRow:hover { background: $unselectedHoverColor; } -.ms-Fabric.is-stationary .ms-DetailsRow.is-selected:hover { +.ms-DetailsRow.is-selected:hover { background: $selectedHoverColor; } From fa542acf437e9d1b301d578c3d110d4be18d236c Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 10:54:31 -0700 Subject: [PATCH 24/27] A few more fixes to test page and styles. --- src/components/DetailsList/DetailsRow.scss | 2 +- .../examples/MarqueeSelection.Basic.Example.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/DetailsList/DetailsRow.scss b/src/components/DetailsList/DetailsRow.scss index 9a3c0e4f58256..da51c2d1c6801 100644 --- a/src/components/DetailsList/DetailsRow.scss +++ b/src/components/DetailsList/DetailsRow.scss @@ -82,7 +82,7 @@ $unselectedHoverColor: $ms-color-neutralLighter; } } -.ms-Fabric.is-stationary .ms-DetailsRow:hover .ms-DetailsRow-check, +.ms-DetailsRow:hover .ms-DetailsRow-check, .ms-DetailsRow.is-selected .ms-DetailsRow-check, .ms-DetailsRow.is-check-visible .ms-DetailsRow-check { opacity: 1; diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx index c1f5772b23fef..6d3cd6cdae79e 100644 --- a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; /* tslint:enable:no-unused-variable */ import { - Checkbox, + Toggle, Selection, MarqueeSelection, css @@ -51,7 +51,7 @@ export class MarqueeSelectionBasicExample extends React.Component<{}, IMarqueeSe public render() { return ( - + Date: Mon, 8 Aug 2016 10:55:12 -0700 Subject: [PATCH 25/27] Removing lint error. --- src/components/MarqueeSelection/MarqueeSelection.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx index 6355ecf5f2461..966cacbff61fa 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -4,7 +4,6 @@ import { BaseComponent } from '../../common/BaseComponent'; import { IMarqueeSelectionProps } from './MarqueeSelection.Props'; import { IPoint } from '../../common/IPoint'; import { IRectangle } from '../../common/IRectangle'; -import { assign } from '../../utilities/object'; import { css } from '../../utilities/css'; import { findScrollableParent } from '../../utilities/scrollUtilities'; import { getDistanceBetweenPoints } from '../../utilities/math'; From e85baa6d522b0b75d94215708a71933fdc7e4d93 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Mon, 8 Aug 2016 11:35:35 -0700 Subject: [PATCH 26/27] Cleanup. --- src/components/DetailsList/DetailsList.scss | 1 - src/components/MarqueeSelection/MarqueeSelection.tsx | 9 +-------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/DetailsList/DetailsList.scss b/src/components/DetailsList/DetailsList.scss index d5ccc7c08d2e2..ae53eb4a8262b 100644 --- a/src/components/DetailsList/DetailsList.scss +++ b/src/components/DetailsList/DetailsList.scss @@ -23,7 +23,6 @@ overflow-y: visible; -webkit-overflow-scrolling: touch; - transform: translateZ(0); } .ms-DetailsList-cell { diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx index 966cacbff61fa..b1fc994ae0e14 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -177,19 +177,12 @@ export class MarqueeSelection extends BaseComponent Date: Mon, 8 Aug 2016 17:11:29 -0700 Subject: [PATCH 27/27] Adds ability to select from anywhere in the scrollable parent. Also fixes column draggging to use native eventing to play well with marquee selection. --- src/components/DetailsList/DetailsHeader.tsx | 17 +++++++++++------ src/components/DetailsList/DetailsList.tsx | 2 +- .../MarqueeSelection/MarqueeSelection.Props.ts | 6 ++++++ .../MarqueeSelection/MarqueeSelection.tsx | 18 ++++++++++++++---- .../examples/DetailsList.Basic.Example.tsx | 2 +- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/components/DetailsList/DetailsHeader.tsx b/src/components/DetailsList/DetailsHeader.tsx index 422e6a78ca278..258723c065e9f 100644 --- a/src/components/DetailsList/DetailsHeader.tsx +++ b/src/components/DetailsList/DetailsHeader.tsx @@ -50,6 +50,7 @@ export interface IColumnResizeDetails { export class DetailsHeader extends BaseComponent { public refs: { [key: string]: React.ReactInstance; + root: HTMLElement; focusZone: FocusZone; }; @@ -70,6 +71,7 @@ export class DetailsHeader extends BaseComponent + ref='root' + data-automationid='DetailsHeader'> { (selectionMode === SelectionMode.multiple) ? (
    @@ -166,10 +169,10 @@ export class DetailsHeader extends BaseComponent { (column.isResizable) ? (
    ) : (null) } @@ -264,14 +267,16 @@ export class DetailsHeader extends BaseComponent { */ isEnabled?: boolean; + /** + * Optional flag to restrict the drag rect to the root element, instead of allowing the drag + * rect to start outside of the root element boundaries. + * @default false + */ + isDraggingConstrainedToRoot?: boolean; } diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx index b1fc994ae0e14..27f93a22a56b6 100644 --- a/src/components/MarqueeSelection/MarqueeSelection.tsx +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -56,6 +56,14 @@ export class MarqueeSelection extends BaseComponent { children } { dragRect && (
    ) } @@ -120,7 +128,6 @@ export class MarqueeSelection extends BaseComponent MIN_DRAG_DISTANCE) { // We need to constrain the current point to the rootRect boundaries. - let constrainedPoint = { + let constrainedPoint = this.props.isDraggingConstrainedToRoot ? { x: Math.max(0, Math.min(rootRect.width, this._lastMouseEvent.clientX - rootRect.left)), y: Math.max(0, Math.min(rootRect.height, this._lastMouseEvent.clientY - rootRect.top)) + } : { + x: this._lastMouseEvent.clientX - rootRect.left, + y: this._lastMouseEvent.clientY - rootRect.top }; let dragRect = { diff --git a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx index 275139543641c..757c208c5eda0 100644 --- a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx +++ b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx @@ -34,7 +34,7 @@ export class DetailsListBasicExample extends React.Component { onChanged={ text => this.setState({ filterText: text }) } /> - +
    );