From 2c859dae902401d65a3c7f6862bb70f5c8a89489 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Thu, 18 Aug 2016 16:14:00 -0700 Subject: [PATCH 1/6] Allowing specific elements to immediately invoke the item. --- src/utilities/selection/SelectionZone.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/utilities/selection/SelectionZone.tsx b/src/utilities/selection/SelectionZone.tsx index 01da9dc5e1c774..55d62199407218 100644 --- a/src/utilities/selection/SelectionZone.tsx +++ b/src/utilities/selection/SelectionZone.tsx @@ -26,6 +26,7 @@ import { const SELECTION_INDEX_ATTRIBUTE_NAME = 'data-selection-index'; const SELECTION_TOGGLE_ATTRIBUTE_NAME = 'data-selection-toggle'; +const SELECTION_INVOKE_ATTRIBUTE_NAME = 'data-selection-invoke'; const SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME = 'data-selection-all-toggle'; export interface ISelectionZoneProps extends React.Props { @@ -154,7 +155,7 @@ export class SelectionZone extends React.Component { private _onClick(ev: React.MouseEvent) { let target = ev.target as HTMLElement; let { selection, selectionMode, onItemInvoked } = this.props; - let isToggleElement = this._isToggleElement(target, SELECTION_TOGGLE_ATTRIBUTE_NAME) || ev.ctrlKey || ev.metaKey; + let isToggleElement = this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME) || ev.ctrlKey || ev.metaKey; let index = this._getIndexFromElement(target, true); if (index >= 0 && selectionMode !== SelectionMode.none) { @@ -163,7 +164,11 @@ export class SelectionZone extends React.Component { // Disable change events. selection.setChangeEvents(false); - if (ev.shiftKey && selectionMode === SelectionMode.multiple) { + let isInvokable = this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME); + + if (isInvokable && onItemInvoked) { + onItemInvoked(selection.getItems()[index], index, ev.nativeEvent); + } else if (ev.shiftKey && selectionMode === SelectionMode.multiple) { if (!ev.ctrlKey && !ev.metaKey) { selection.setAllSelected(false); } @@ -185,7 +190,7 @@ export class SelectionZone extends React.Component { private _onDoubleClick(ev: React.MouseEvent) { let target = ev.target as HTMLElement; - let isToggleElement = this._isToggleElement(target, SELECTION_TOGGLE_ATTRIBUTE_NAME) || ev.ctrlKey || ev.metaKey; + let isToggleElement = this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME) || ev.ctrlKey || ev.metaKey; if (isToggleElement) { return; @@ -208,8 +213,8 @@ export class SelectionZone extends React.Component { private _onKeyDown(ev: KeyboardEvent) { let target = ev.target as HTMLElement; let { selection, selectionMode, onItemInvoked } = this.props; - let isToggleElement = this._isToggleElement(target, SELECTION_TOGGLE_ATTRIBUTE_NAME); - let isToggleAllElement = !isToggleElement && this._isToggleElement(target, SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME); + let isToggleElement = this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME); + let isToggleAllElement = !isToggleElement && this._hasAttribute(target, SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME); let index = this._getIndexFromElement(target, true); if (index >= 0 && !this._isInputElement(target) && selectionMode !== SelectionMode.none) { @@ -263,7 +268,7 @@ export class SelectionZone extends React.Component { ev.stopPropagation(); } - private _isToggleElement(element: HTMLElement, attributeName: string) { + private _hasAttribute(element: HTMLElement, attributeName: string) { let isToggle = false; while (!isToggle && element !== this.refs.root) { From c4bf38386a4cf5a776a937dd4550e4302fa677ef Mon Sep 17 00:00:00 2001 From: David Zearing Date: Fri, 19 Aug 2016 18:41:45 -0700 Subject: [PATCH 2/6] Addding tests. --- src/utilities/selection/Selection.ts | 5 + .../selection/SelectionZone.test.tsx | 116 ++++++++++++++++++ src/utilities/selection/SelectionZone.tsx | 17 +-- 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 src/utilities/selection/SelectionZone.test.tsx diff --git a/src/utilities/selection/Selection.ts b/src/utilities/selection/Selection.ts index ef070bccb877b3..8a875f61b36402 100644 --- a/src/utilities/selection/Selection.ts +++ b/src/utilities/selection/Selection.ts @@ -175,6 +175,11 @@ export class Selection implements ISelection { // Clamp the index. index = Math.min(Math.max(0, index), this._items.length - 1); + // No-op on out of bounds selections. + if (index < 0) { + return; + } + let isExempt = this._exemptedIndices[index]; let hasChanged = false; let canSelect = !this._unselectableIndices[index]; diff --git a/src/utilities/selection/SelectionZone.test.tsx b/src/utilities/selection/SelectionZone.test.tsx new file mode 100644 index 00000000000000..024ecf526a8e59 --- /dev/null +++ b/src/utilities/selection/SelectionZone.test.tsx @@ -0,0 +1,116 @@ +/* tslint:disable:no-unused-variable */ +import * as React from 'react'; +/* tslint:enable:no-unused-variable */ + +import * as ReactDOM from 'react-dom'; +import * as ReactTestUtils from 'react-addons-test-utils'; + +let { assert } = chai; + +import { SelectionZone, Selection, SelectionMode } from './index'; + +let _selection: Selection; +let _selectionZone; +let _componentElement: Element; +let _surface0: Element; +let _invoke0: Element; +let _toggle0: Element; +let _surface1: Element; +let _invoke1: Element; +let _toggle1: Element; +let _onItemInvokeCalled: number; +let _lastItemInvoked: any; + +describe('SelectionZone', () => { + beforeEach(() => { + _selection = new Selection(); + _selection.setItems([ { key: 'a', }, { key: 'b' } ]); + _selectionZone = ReactTestUtils.renderIntoDocument( + { _onItemInvokeCalled++; _lastItemInvoked = item; }}> + +
+ + +
+ +
+ + +
+ +
+ ); + _componentElement = ReactDOM.findDOMNode(_selectionZone); + _surface0 = _componentElement.querySelector('#surface0'); + _invoke0 = _componentElement.querySelector('#invoke0'); + _toggle0 = _componentElement.querySelector('#toggle0'); + _surface1 = _componentElement.querySelector('#surface1'); + _invoke1 = _componentElement.querySelector('#invoke1'); + _toggle1 = _componentElement.querySelector('#toggle1'); + _onItemInvokeCalled = 0; + }); + + it('toggles an item on click of toggle element', () => { + ReactTestUtils.Simulate.click(_toggle0); + assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); + ReactTestUtils.Simulate.click(_toggle0); + assert(_selection.isIndexSelected(0) === false, 'Index 0 selected'); + assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); + }); + + it('toggles an item on dblclick of toggle element', () => { + ReactTestUtils.Simulate.doubleClick(_toggle0); + assert(_selection.isIndexSelected(0) === false, 'Index 0 selected'); + assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); + }); + + it('does not toggle an item on mousedown of toggle element', () => { + ReactTestUtils.Simulate.mouseDown(_toggle0); + assert(_selection.isIndexSelected(0) === false, 'Index 0 selected'); + assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); + }); + + it('selects an item on mousedown of invoke element', () => { + ReactTestUtils.Simulate.mouseDown(_invoke0); + assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); + }); + + it('selects an item on mousedown of invoke element', () => { + ReactTestUtils.Simulate.mouseDown(_invoke0); + assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); + }); + + it('does not change selection for an item on click of invoke element', () => { + ReactTestUtils.Simulate.click(_invoke0); + assert(_selection.isIndexSelected(0) === false, 'Index 0 selected'); + assert(_onItemInvokeCalled === 1, 'onItemInvoked was not called 1 time'); + }); + + it('selects an unselected item on click of item surface element', () => { + ReactTestUtils.Simulate.click(_surface0); + assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); + assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); + }); + + it('does not unselect a selected item on click of item surface element', () => { + _selection.setIndexSelected(0, true, true); + ReactTestUtils.Simulate.click(_surface0); + assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); + assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); + }); + + it('does not select an unselected item on mousedown of item surface element', () => { + ReactTestUtils.Simulate.mouseDown(_surface0); + assert(_selection.isIndexSelected(0) === false, 'Index 0 selected '); + }); + + it('invokes an item on double clicking the surface element', () => { + ReactTestUtils.Simulate.doubleClick(_surface0); + assert(_onItemInvokeCalled === 1, 'Item was invoked'); + assert(_lastItemInvoked.key === 'a', 'Item invoked was not expected item'); + }); + +}); diff --git a/src/utilities/selection/SelectionZone.tsx b/src/utilities/selection/SelectionZone.tsx index 55d62199407218..d99385548e7eee 100644 --- a/src/utilities/selection/SelectionZone.tsx +++ b/src/utilities/selection/SelectionZone.tsx @@ -66,6 +66,7 @@ export class SelectionZone extends React.Component { // React and non React events to stop propagation and avoid the default SelectionZone // behaviors (like executing onInvoked.) this._onClick = this._onClick.bind(this); + this._onMouseDown = this._onMouseDown.bind(this); this._onDoubleClick = this._onDoubleClick.bind(this); } @@ -73,8 +74,7 @@ export class SelectionZone extends React.Component { let element = this.refs.root; this._events.onAll(element, { - 'keydown': this._onKeyDown, - 'mousedown': this._onMouseDown + 'keydown': this._onKeyDown }); // Always know what the state of shift/ctrl/meta are. @@ -93,6 +93,7 @@ export class SelectionZone extends React.Component {
@@ -139,7 +140,7 @@ export class SelectionZone extends React.Component { this._hasClickedOnItem = false; } - private _onMouseDown(ev: MouseEvent) { + private _onMouseDown(ev: React.MouseEvent) { // We need to reset the key states for ctrl/meta/etc. this._onKeyChangeCapture(ev as any); @@ -164,11 +165,7 @@ export class SelectionZone extends React.Component { // Disable change events. selection.setChangeEvents(false); - let isInvokable = this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME); - - if (isInvokable && onItemInvoked) { - onItemInvoked(selection.getItems()[index], index, ev.nativeEvent); - } else if (ev.shiftKey && selectionMode === SelectionMode.multiple) { + if (ev.shiftKey && selectionMode === SelectionMode.multiple) { if (!ev.ctrlKey && !ev.metaKey) { selection.setAllSelected(false); } @@ -179,6 +176,10 @@ export class SelectionZone extends React.Component { } selection.setIndexSelected(index, isToggleElement ? !isSelected : true, !ev.shiftKey); + + if (!isToggleElement && onItemInvoked && this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME)) { + onItemInvoked(selection.getItems()[index], index, ev.nativeEvent); + } } // Re-enabled change events. From 112a3347e50cb9936e70db93762be0c5c765589d Mon Sep 17 00:00:00 2001 From: David Zearing Date: Sun, 21 Aug 2016 16:21:38 -0700 Subject: [PATCH 3/6] Adding significantly more unit test coverage. --- src/components/Link/Link.scss | 4 + src/components/Link/Link.tsx | 2 +- .../examples/DetailsList.Basic.Example.tsx | 22 +- .../examples/Selection.Basic.Example.tsx | 9 +- .../examples/Selection.Example.scss | 4 + src/utilities/selection/Selection.ts | 12 +- .../selection/SelectionZone.test.tsx | 195 +++++++-- src/utilities/selection/SelectionZone.tsx | 399 +++++++++++------- src/utilities/selection/interfaces.ts | 4 +- 9 files changed, 441 insertions(+), 210 deletions(-) diff --git a/src/components/Link/Link.scss b/src/components/Link/Link.scss index def5dffe1fef25..5658634d3f5138 100644 --- a/src/components/Link/Link.scss +++ b/src/components/Link/Link.scss @@ -52,5 +52,9 @@ BUTTON.ms-Link { display: inline; padding: 0; margin: 0; + + width: inherit; + overflow: inherit; + text-overflow: inherit; } diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index 5b0e4eea697808..667cae23b7f3c5 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -37,7 +37,7 @@ export class Link extends React.Component { { ...this.props as any } className={ css('ms-Link', className) } onClick={ this._onClick } > - { children } + { children } )); } diff --git a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx index 323aa39b983ac8..f71b8e49086d95 100644 --- a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx +++ b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx @@ -5,7 +5,8 @@ import { DetailsList, MarqueeSelection, Selection, - TextField + TextField, + Link } from '../../../../index'; import { createListItems } from '../../../utilities/data'; @@ -21,6 +22,7 @@ export class DetailsListBasicExample extends React.Component { this._selection = new Selection(); this.state = { filterText: '' }; + this._onRenderItemColumn = this._onRenderItemColumn.bind(this); } public render() { @@ -32,13 +34,27 @@ export class DetailsListBasicExample extends React.Component { this.setState({ filterText: text }) } - /> + /> - + alert(`Item invoked: ${item.name}`) } + onRenderItemColumn={ this._onRenderItemColumn } + />
); } + private _onRenderItemColumn(item, index, column) { + if (column.key === 'name') { + return { item[column.key] }; + } + + return item[column.key]; + } } diff --git a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx index c45961e30e0816..8d40138849a2d3 100644 --- a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx +++ b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx @@ -64,7 +64,10 @@ export class SelectionBasicExample extends React.Component - + alert('item invoked: ' + item.name) }> { items.map((item, index) => ( { (selectionMode !== SelectionMode.none) && ( - + ) } { item.name } diff --git a/src/demo/pages/SelectionPage/examples/Selection.Example.scss b/src/demo/pages/SelectionPage/examples/Selection.Example.scss index 98b72f95a43b75..b370354beb6f8b 100644 --- a/src/demo/pages/SelectionPage/examples/Selection.Example.scss +++ b/src/demo/pages/SelectionPage/examples/Selection.Example.scss @@ -10,6 +10,10 @@ border: none; } +.ms-SelectionItemExample:hover { + background: #EEE; +} + .ms-SelectionItemExample-name { display: inline-block; overflow: hidden; diff --git a/src/utilities/selection/Selection.ts b/src/utilities/selection/Selection.ts index 8a875f61b36402..d78d309613b2d8 100644 --- a/src/utilities/selection/Selection.ts +++ b/src/utilities/selection/Selection.ts @@ -176,7 +176,7 @@ export class Selection implements ISelection { index = Math.min(Math.max(0, index), this._items.length - 1); // No-op on out of bounds selections. - if (index < 0) { + if (index < 0 || index >= this._items.length) { return; } @@ -215,17 +215,21 @@ export class Selection implements ISelection { } } - public selectToKey(key: string) { - this.selectToIndex(this._keyToIndexMap[key]); + public selectToKey(key: string, clearSelection?: boolean) { + this.selectToIndex(this._keyToIndexMap[key], clearSelection); } - public selectToIndex(index: number) { + public selectToIndex(index: number, clearSelection?: boolean) { let anchorIndex = this._anchoredIndex || 0; let startIndex = Math.min(index, anchorIndex); let endIndex = Math.max(index, anchorIndex); this.setChangeEvents(false); + if (clearSelection) { + this.setAllSelected(false); + } + for (; startIndex <= endIndex; startIndex++) { this.setIndexSelected(startIndex, true, false); } diff --git a/src/utilities/selection/SelectionZone.test.tsx b/src/utilities/selection/SelectionZone.test.tsx index 024ecf526a8e59..58f66146edbb27 100644 --- a/src/utilities/selection/SelectionZone.test.tsx +++ b/src/utilities/selection/SelectionZone.test.tsx @@ -4,59 +4,81 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import * as ReactTestUtils from 'react-addons-test-utils'; - -let { assert } = chai; +let { assert, expect } = chai; import { SelectionZone, Selection, SelectionMode } from './index'; +import { KeyCodes } from '../KeyCodes'; let _selection: Selection; -let _selectionZone; +let _selectionZone: any; let _componentElement: Element; +let _toggleAll: Element; let _surface0: Element; let _invoke0: Element; let _toggle0: Element; let _surface1: Element; let _invoke1: Element; let _toggle1: Element; +let _invoke2: Element; +let _toggle2: Element; +let _surface3: Element; + let _onItemInvokeCalled: number; let _lastItemInvoked: any; +function _initializeSelection(selectionMode = SelectionMode.multiple) { + _selection = new Selection(); + _selection.setItems([{ key: 'a', }, { key: 'b' }, { key: 'c' }, { key: 'd' }]); + _selectionZone = ReactTestUtils.renderIntoDocument( + { _onItemInvokeCalled++; _lastItemInvoked = item; } }> + + + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ ); + + _componentElement = ReactDOM.findDOMNode(_selectionZone); + _toggleAll = _componentElement.querySelector('#toggleAll'); + _surface0 = _componentElement.querySelector('#surface0'); + _invoke0 = _componentElement.querySelector('#invoke0'); + _toggle0 = _componentElement.querySelector('#toggle0'); + _surface1 = _componentElement.querySelector('#surface1'); + _invoke1 = _componentElement.querySelector('#invoke1'); + _toggle1 = _componentElement.querySelector('#toggle1'); + _invoke2 = _componentElement.querySelector('#invoke2'); + _toggle2 = _componentElement.querySelector('#toggle2'); + _surface3 = _componentElement.querySelector('#surface3'); + + _onItemInvokeCalled = 0; + _lastItemInvoked = undefined; +} + describe('SelectionZone', () => { - beforeEach(() => { - _selection = new Selection(); - _selection.setItems([ { key: 'a', }, { key: 'b' } ]); - _selectionZone = ReactTestUtils.renderIntoDocument( - { _onItemInvokeCalled++; _lastItemInvoked = item; }}> - -
- - -
- -
- - -
- -
- ); - _componentElement = ReactDOM.findDOMNode(_selectionZone); - _surface0 = _componentElement.querySelector('#surface0'); - _invoke0 = _componentElement.querySelector('#invoke0'); - _toggle0 = _componentElement.querySelector('#toggle0'); - _surface1 = _componentElement.querySelector('#surface1'); - _invoke1 = _componentElement.querySelector('#invoke1'); - _toggle1 = _componentElement.querySelector('#toggle1'); - _onItemInvokeCalled = 0; - }); + beforeEach(() => _initializeSelection()); it('toggles an item on click of toggle element', () => { - ReactTestUtils.Simulate.click(_toggle0); + _simulateClick(_toggle0); assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); - ReactTestUtils.Simulate.click(_toggle0); + _simulateClick(_toggle0); assert(_selection.isIndexSelected(0) === false, 'Index 0 selected'); assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); }); @@ -73,38 +95,44 @@ describe('SelectionZone', () => { assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); }); - it('selects an item on mousedown of invoke element', () => { + it('selects an unselected item on mousedown of invoke without modifiers pressed', () => { + _selection.setAllSelected(true); + _selection.setIndexSelected(0, false, true); + + // Mousedown on the only unselected item's invoke surface should deselect all and select that one. ReactTestUtils.Simulate.mouseDown(_invoke0); - assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); + expect(_selection.isIndexSelected(0)).equals(true, 'Index 0 not selected after mousedown'); + expect(_selection.getSelectedCount()).equals(1, 'Only 1 item should be selected'); }); - it('selects an item on mousedown of invoke element', () => { + it('does nothing with mousedown of invoke when item is selected already', () => { + // Mousedown on an item that's already selected should do nothing. + _selection.setAllSelected(true); ReactTestUtils.Simulate.mouseDown(_invoke0); - assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); + expect(_selection.isAllSelected()).equals(true, 'Expecting all items to be selected'); }); - it('does not change selection for an item on click of invoke element', () => { - ReactTestUtils.Simulate.click(_invoke0); - assert(_selection.isIndexSelected(0) === false, 'Index 0 selected'); - assert(_onItemInvokeCalled === 1, 'onItemInvoked was not called 1 time'); + it('calls the invoke callback on click of invoke area', () => { + _simulateClick(_invoke0); + assert(_onItemInvokeCalled === 1, 'onItemInvoked was not called 1 time after normal click'); }); it('selects an unselected item on click of item surface element', () => { - ReactTestUtils.Simulate.click(_surface0); + _simulateClick(_surface0); assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); }); it('does not unselect a selected item on click of item surface element', () => { _selection.setIndexSelected(0, true, true); - ReactTestUtils.Simulate.click(_surface0); + _simulateClick(_surface0); assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected'); assert(_onItemInvokeCalled === 0, 'onItemInvoked was called'); }); it('does not select an unselected item on mousedown of item surface element', () => { ReactTestUtils.Simulate.mouseDown(_surface0); - assert(_selection.isIndexSelected(0) === false, 'Index 0 selected '); + assert(_selection.isIndexSelected(0) === false, 'Index 0 selected'); }); it('invokes an item on double clicking the surface element', () => { @@ -113,4 +141,79 @@ describe('SelectionZone', () => { assert(_lastItemInvoked.key === 'a', 'Item invoked was not expected item'); }); + it('toggles all on toggle-all clicks', () => { + _simulateClick(_toggleAll); + expect(_selection.getSelectedCount()).equals(4, 'There were not 4 selected items'); + + _simulateClick(_toggle1); + expect(_selection.getSelectedCount()).equals(3, 'There were not 3 selected items after toggling index 1'); + + _simulateClick(_toggleAll); + expect(_selection.getSelectedCount()).equals(4, 'There were not 4 selected items after selecting all again'); + + _simulateClick(_toggleAll); + expect(_selection.getSelectedCount()).equals(0, 'There were not 0 selected items'); + }); + + it('suports mouse shift click range select scenarios', () => { + _simulateClick(_surface1); + expect(_selection.getSelectedCount()).equals(1, 'Clicked surface 1'); + + _simulateClick(_surface3, { shiftKey: true }); + expect(_selection.getSelectedCount()).equals(3, 'After clicking surface 1 and then shift clicking to surface 3'); + + _simulateClick(_surface0, { shiftKey: true }); + expect(_selection.getSelectedCount()).equals(2, 'After shift clicking surface 0'); + }); + + it('toggles by ctrl clicking a surface', () => { + _simulateClick(_toggleAll); + assert(_selection.getSelectedCount() === 4, 'There were not 4 selected items'); + + _simulateClick(_surface1, { + ctrlKey: true + }); + assert(_selection.getSelectedCount() === 3, 'There were not 3 selected items'); + }); + + it ('selects all on ctrl-a', () => { + ReactTestUtils.Simulate.keyDown(_componentElement, { ctrlKey: true, which: KeyCodes.a }); + expect(_selection.isAllSelected()).equals(true, 'Expecting that all is selected aftr ctrl-a'); + }); + + it('unselects all on escape', () => { + _selection.setAllSelected(true); + ReactTestUtils.Simulate.keyDown(_componentElement, { which: KeyCodes.escape }); + expect(_selection.getSelectedCount()).equals(0, 'Expecting that none is selected aftr escape'); + }); + + it('selects item on focus', () => { + ReactTestUtils.Simulate.focus(_surface0); + expect(_selection.isIndexSelected(0)).equals(true, 'Item 0 was not selected'); + }); + + it('does not select an item on focus if ctrl/meta is pressed', () => { + ReactTestUtils.Simulate.keyDown(_componentElement, { ctrlKey: true }); + ReactTestUtils.Simulate.focus(_surface0); + expect(_selection.isIndexSelected(0)).equals(false, 'Item 0 was selected on focus with modifier'); + }); + + it('does not select an item on focus when ignoreNextFocus is called', () => { + _selectionZone.ignoreNextFocus(); + ReactTestUtils.Simulate.focus(_surface0); + expect(_selection.isIndexSelected(0)).equals(false, 'Item 0 was selected on ignored focus'); + }); + + it('toggles an item when pressing space', () => { + ReactTestUtils.Simulate.keyDown(_surface0, { which: KeyCodes.space }); + expect(_selection.isIndexSelected(0)).equals(true, 'Expecting index 0 to become selected'); + ReactTestUtils.Simulate.keyDown(_surface0, { which: KeyCodes.space }); + expect(_selection.isIndexSelected(0)).equals(false, 'Expecting index 0 to become unselected'); + }); }); + +function _simulateClick(el, eventData?: React.SyntheticEventData) { + ReactTestUtils.Simulate.mouseDown(el, eventData); + ReactTestUtils.Simulate.focus(el, eventData); + ReactTestUtils.Simulate.click(el, eventData); +} diff --git a/src/utilities/selection/SelectionZone.tsx b/src/utilities/selection/SelectionZone.tsx index d99385548e7eee..eb9ddfb4f2caab 100644 --- a/src/utilities/selection/SelectionZone.tsx +++ b/src/utilities/selection/SelectionZone.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { EventGroup } from '../eventGroup/EventGroup'; +import { BaseComponent } from '../../common/BaseComponent'; import { SelectionLayout } from './SelectionLayout'; import { KeyCodes } from '../KeyCodes'; import { @@ -24,10 +24,10 @@ import { // If you click index 8 // The anchor and focus are set to 8. -const SELECTION_INDEX_ATTRIBUTE_NAME = 'data-selection-index'; -const SELECTION_TOGGLE_ATTRIBUTE_NAME = 'data-selection-toggle'; -const SELECTION_INVOKE_ATTRIBUTE_NAME = 'data-selection-invoke'; -const SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME = 'data-selection-all-toggle'; +export const SELECTION_INDEX_ATTRIBUTE_NAME = 'data-selection-index'; +export const SELECTION_TOGGLE_ATTRIBUTE_NAME = 'data-selection-toggle'; +export const SELECTION_INVOKE_ATTRIBUTE_NAME = 'data-selection-invoke'; +export const SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME = 'data-selection-all-toggle'; export interface ISelectionZoneProps extends React.Props { selection: ISelection; @@ -37,7 +37,7 @@ export interface ISelectionZoneProps extends React.Props { onItemInvoked?: (item?: any, index?: number, ev?: Event) => void; } -export class SelectionZone extends React.Component { +export class SelectionZone extends BaseComponent { public static defaultProps = { layout: new SelectionLayout(SelectionDirection.vertical), isMultiSelectEnabled: true, @@ -49,43 +49,29 @@ export class SelectionZone extends React.Component { root: HTMLElement }; - private _events: EventGroup; - private _isCtrlPressed: boolean; private _isShiftPressed: boolean; private _isMetaPressed: boolean; - private _hasClickedOnItem: boolean; private _shouldIgnoreFocus: boolean; - constructor() { - super(); - - this._events = new EventGroup(this); + constructor(props: ISelectionZoneProps) { + super(props); // Specifically for the click methods, we will want to use React eventing to allow // React and non React events to stop propagation and avoid the default SelectionZone // behaviors (like executing onInvoked.) + this._onFocus = this._onFocus.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); this._onClick = this._onClick.bind(this); this._onMouseDown = this._onMouseDown.bind(this); this._onDoubleClick = this._onDoubleClick.bind(this); + this._updateModifiers = this._updateModifiers.bind(this); + this.ignoreNextFocus = this.ignoreNextFocus.bind(this); } public componentDidMount() { - let element = this.refs.root; - - this._events.onAll(element, { - 'keydown': this._onKeyDown - }); - - // Always know what the state of shift/ctrl/meta are. - this._events.on(element, 'focus', this._onFocus, true); - this._events.on(window, 'keydown', this._onKeyChangeCapture, true); - this._events.on(window, 'keyup', this._onKeyChangeCapture, true); - - } - - public componentWillUnmount() { - this._events.dispose(); + // Track the latest modifier keys globally. + this._events.on(window, 'keydown keyup', this._updateModifiers); } public render() { @@ -93,6 +79,9 @@ export class SelectionZone extends React.Component {
{ this._shouldIgnoreFocus = true; } - private _onFocus(ev: FocusEvent) { - if (this._shouldIgnoreFocus) { + /** + * When we focus an item, for single/multi select scenarios, we should try to select it immediately + * as long as the focus did not originate from a mouse down/touch event. For those cases, we handle them + * specially. + */ + private _onFocus(ev: React.FocusEvent) { + let target = ev.target as HTMLElement; + let { selection, selectionMode } = this.props; + let isToggleModifierPressed = this._isCtrlPressed || this._isMetaPressed; + + if (this._shouldIgnoreFocus || selectionMode === SelectionMode.none) { this._shouldIgnoreFocus = false; return; } - let { selection, selectionMode } = this.props; - let index = this._getIndexFromElement(ev.target as HTMLElement); + let isToggle = this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME); + let itemRoot = this._findItemRoot(target); - if (index >= 0 && selectionMode !== SelectionMode.none && !this._hasClickedOnItem) { - selection.setChangeEvents(false); + if (!isToggle && itemRoot) { + let index = this._getItemIndex(itemRoot); - if (this._isShiftPressed && selectionMode === SelectionMode.multiple) { - if (!this._isCtrlPressed && !this._isMetaPressed) { - selection.setAllSelected(false); - } - selection.selectToIndex(index); - } else if (!this._isCtrlPressed && !this._isMetaPressed) { - selection.setAllSelected(false); - selection.setIndexSelected(index, true, true); + if (isToggleModifierPressed) { + // set anchor only. + selection.setIndexSelected(index, selection.isIndexSelected(index), true); + } else { + this._onItemSurfaceClick(ev, index); } - - selection.setChangeEvents(true); } - - this._hasClickedOnItem = false; } private _onMouseDown(ev: React.MouseEvent) { - // We need to reset the key states for ctrl/meta/etc. - this._onKeyChangeCapture(ev as any); + this._updateModifiers(ev); let target = ev.target as HTMLElement; - let { selectionMode } = this.props; - let index = this._getIndexFromElement(target, true); + let itemRoot = this._findItemRoot(target); + + while (target !== this.refs.root) { + if (this._hasAttribute(target, SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME)) { + break; + } else if (itemRoot) { + if (this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME)) { + break; + } else if (this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME)) { + this._onInvokeMouseDown(ev, this._getItemIndex(itemRoot)); + break; + } else if (target === itemRoot) { + break; + } + } - if (index >= 0 && selectionMode !== SelectionMode.none) { - this._hasClickedOnItem = true; + target = target.parentElement; } } private _onClick(ev: React.MouseEvent) { + this._updateModifiers(ev); + let target = ev.target as HTMLElement; - let { selection, selectionMode, onItemInvoked } = this.props; - let isToggleElement = this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME) || ev.ctrlKey || ev.metaKey; - let index = this._getIndexFromElement(target, true); + let itemRoot = this._findItemRoot(target); - if (index >= 0 && selectionMode !== SelectionMode.none) { - let isSelected = selection.isIndexSelected(index); + while (target !== this.refs.root) { + if (this._hasAttribute(target, SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME)) { + this._onToggleAllClick(ev); + break; + } else if (itemRoot) { + let index = this._getItemIndex(itemRoot); + + if (this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME)) { + if (this._isShiftPressed) { + this._onItemSurfaceClick(ev, index); + } else { + this._onToggleClick(ev, index); + } + break; + } else if (this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME)) { + this._onInvokeClick(ev, index); + break; + } else if (target === itemRoot) { + this._onItemSurfaceClick(ev, index); + break; + } + } - // Disable change events. - selection.setChangeEvents(false); + target = target.parentElement; + } + } - if (ev.shiftKey && selectionMode === SelectionMode.multiple) { - if (!ev.ctrlKey && !ev.metaKey) { - selection.setAllSelected(false); - } - selection.selectToIndex(index); - } else { - if (selectionMode === SelectionMode.single || !isToggleElement) { - selection.setAllSelected(false); + /** + * In multi selection, if you double click within an item's root (but not within the invoke element or input elements), + * we should execute the invoke handler. + */ + private _onDoubleClick(ev: React.MouseEvent) { + let target = ev.target as HTMLElement; + let { selectionMode, onItemInvoked } = this.props; + let itemRoot = this._findItemRoot(target); + + if (itemRoot && onItemInvoked && selectionMode !== SelectionMode.none && !this._isInputElement(target)) { + let index = this._getItemIndex(itemRoot); + + while (target !== this.refs.root) { + if ( + this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME) || + this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME)) { + break; + } else if (target === itemRoot) { + this._onInvokeClick(ev, index); + break; } - selection.setIndexSelected(index, isToggleElement ? !isSelected : true, !ev.shiftKey); + target = target.parentElement; + } + + target = target.parentElement; + } + } + + private _onKeyDown(ev: React.KeyboardEvent) { + this._updateModifiers(ev); + + let target = ev.target as HTMLElement; + let { selection, selectionMode } = this.props; + let isSelectAllKey = ev.which === KeyCodes.a && (this._isCtrlPressed || this._isMetaPressed); + let isClearSelectionKey = ev.which === KeyCodes.escape; + + // Ignore key downs from input elements. + if (this._isInputElement(target)) { + return; + } + + // If ctrl-a is pressed, select all (if all are not already selected.) + if (isSelectAllKey && selectionMode === SelectionMode.multiple && !selection.isAllSelected()) { + selection.setAllSelected(true); + ev.stopPropagation(); + ev.preventDefault(); + return; + } - if (!isToggleElement && onItemInvoked && this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME)) { - onItemInvoked(selection.getItems()[index], index, ev.nativeEvent); + // If escape is pressed, clear selection (if any are selected.) + if (isClearSelectionKey && selection.getSelectedCount() > 0) { + selection.setAllSelected(false); + ev.stopPropagation(); + ev.preventDefault(); + return; + } + + let itemRoot = this._findItemRoot(target); + + // If a key was pressed within an item, we should treat "enters" as invokes and "space" as toggle + if (itemRoot) { + let index = this._getItemIndex(itemRoot); + + while (target !== this.refs.root) { + if (this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME)) { + // For toggle elements, assuming they are rendered as buttons, they will generate a click event, + // so we can no-op for any keydowns in this case. + break; + } else if (target === itemRoot) { + if (ev.which === KeyCodes.enter) { + this._onInvokeClick(ev, index); + } else if (ev.which === KeyCodes.space) { + this._onToggleClick(ev, index); + } + break; } + + target = target.parentElement; } + } + } - // Re-enabled change events. - selection.setChangeEvents(true); - } else if (onItemInvoked) { - onItemInvoked(selection.getItems()[index], index, ev.nativeEvent); + private _onToggleAllClick(ev: React.MouseEvent) { + let { selection, selectionMode } = this.props; + + if (selectionMode === SelectionMode.multiple) { + selection.toggleAllSelected(); + ev.stopPropagation(); + ev.preventDefault(); } } - private _onDoubleClick(ev: React.MouseEvent) { - let target = ev.target as HTMLElement; - let isToggleElement = this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME) || ev.ctrlKey || ev.metaKey; + private _onToggleClick(ev: React.MouseEvent | React.KeyboardEvent, index: number) { + let { selection, selectionMode } = this.props; - if (isToggleElement) { + if (selectionMode === SelectionMode.multiple) { + selection.toggleIndexSelected(index); + } else if (selectionMode === SelectionMode.single) { + let isSelected = selection.isIndexSelected(index); + selection.setChangeEvents(false); + selection.setAllSelected(false); + selection.setIndexSelected(index, !isSelected, true); + } else { return; } - let { onItemInvoked, selection } = this.props; - let index = this._getIndexFromElement(target, true); + ev.stopPropagation(); + ev.preventDefault(); + } + + private _onInvokeClick(ev: React.MouseEvent | React.KeyboardEvent, index: number) { + let { selection, onItemInvoked } = this.props; - if (onItemInvoked && index >= 0) { + if (onItemInvoked) { onItemInvoked(selection.getItems()[index], index, ev.nativeEvent); + ev.preventDefault(); + ev.stopPropagation(); + } + } + + private _onItemSurfaceClick(ev: React.SyntheticEvent, index: number) { + let { selection, selectionMode } = this.props; + let isToggleModifierPressed = this._isCtrlPressed || this._isMetaPressed; + + if (selectionMode === SelectionMode.multiple) { + if (this._isShiftPressed) { + selection.selectToIndex(index, !isToggleModifierPressed); + } else if (isToggleModifierPressed) { + selection.toggleIndexSelected(index); + } else { + this._clearAndSelectIndex(index); + } + } else if (selectionMode === SelectionMode.single) { + this._clearAndSelectIndex(index); + } + } + + private _onInvokeMouseDown(ev: React.MouseEvent | React.KeyboardEvent, index: number) { + let { selection } = this.props; + + // Only do work if item is not selected. + if (selection.isIndexSelected(index)) { + return; + } + + this._clearAndSelectIndex(index); + } + + private _clearAndSelectIndex(index: number) { + let { selection } = this.props; + let isAlreadySingleSelected = selection.getSelectedCount() === 1 && selection.isIndexSelected(index); + + if (!isAlreadySingleSelected) { + selection.setChangeEvents(false); + selection.setAllSelected(false); + selection.setIndexSelected(index, true, true); + selection.setChangeEvents(true); } } - private _onKeyChangeCapture(ev: KeyboardEvent) { + /** + * We need to track the modifier key states so that when focus events occur, which do not contain + * modifier states in the Event object, we know how to behave. + */ + private _updateModifiers(ev: React.KeyboardEvent | React.MouseEvent) { this._isShiftPressed = ev.shiftKey; this._isCtrlPressed = ev.ctrlKey; this._isMetaPressed = ev.metaKey; } - private _onKeyDown(ev: KeyboardEvent) { - let target = ev.target as HTMLElement; - let { selection, selectionMode, onItemInvoked } = this.props; - let isToggleElement = this._hasAttribute(target, SELECTION_TOGGLE_ATTRIBUTE_NAME); - let isToggleAllElement = !isToggleElement && this._hasAttribute(target, SELECTALL_TOGGLE_ALL_ATTRIBUTE_NAME); - let index = this._getIndexFromElement(target, true); + private _findItemRoot(target: HTMLElement): HTMLElement { + let { selection } = this.props; - if (index >= 0 && !this._isInputElement(target) && selectionMode !== SelectionMode.none) { - let isSelected = selection.isIndexSelected(index); + while (target !== this.refs.root) { + let indexValue = target.getAttribute(SELECTION_INDEX_ATTRIBUTE_NAME); + let index = Number(indexValue); - if (ev.which === KeyCodes.space) { - if (isToggleAllElement) { - if (selectionMode === SelectionMode.multiple) { - selection.toggleAllSelected(); - } - } else { // an item - selection.setChangeEvents(false); - if (selectionMode === SelectionMode.single) { - selection.setAllSelected(false); - } - selection.setIndexSelected(index, !isSelected, true); - selection.setChangeEvents(true); - } - } else if (ev.which === KeyCodes.enter) { - if (isToggleAllElement) { - selection.toggleAllSelected(); - } else if (isToggleElement) { - selection.setChangeEvents(false); - if (selectionMode === SelectionMode.single) { - selection.setAllSelected(false); - } - selection.setIndexSelected(index, !isSelected, true); - selection.setChangeEvents(true); - } else if (this._getIndexFromElement(target) >= 0 && onItemInvoked) { - // if the target IS the item, and not a link inside, then call the invoke method. - onItemInvoked(selection.getItems()[index], index, ev); - } else { - return; - } - } else if (ev.which === KeyCodes.a && (ev.ctrlKey || ev.metaKey) && selectionMode === SelectionMode.multiple) { - selection.setAllSelected(true); - } else if (ev.which === KeyCodes.escape) { - if (selection.getSelectedCount() > 0) { - selection.setAllSelected(false); - } else { - return; - } - } else { - return; + if (indexValue !== null && index >= 0 && index < selection.getItems().length ) { + break; } - } else { - return; + + target = target.parentElement; } - ev.preventDefault(); - ev.stopPropagation(); + if (target === this.refs.root) { + return undefined; + } + + return target; + } + + private _getItemIndex(itemRoot: HTMLElement): number { + return Number(itemRoot.getAttribute(SELECTION_INDEX_ATTRIBUTE_NAME)); } private _hasAttribute(element: HTMLElement, attributeName: string) { @@ -284,22 +399,4 @@ export class SelectionZone extends React.Component { return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'; } - private _getIndexFromElement(element: HTMLElement, traverseParents?: boolean): number { - let index = -1; - - do { - let indexString = element.getAttribute(SELECTION_INDEX_ATTRIBUTE_NAME); - - if (indexString) { - index = Number(indexString); - break; - } - if (element !== this.refs.root) { - element = element.parentElement; - } - } while (traverseParents && element !== this.refs.root); - - return index; - } - } diff --git a/src/utilities/selection/interfaces.ts b/src/utilities/selection/interfaces.ts index 5baf30f0533523..bd94b7854ddf85 100644 --- a/src/utilities/selection/interfaces.ts +++ b/src/utilities/selection/interfaces.ts @@ -39,8 +39,8 @@ export interface ISelection { // Write range selection methods. - selectToKey(key: string); - selectToIndex(index: number); + selectToKey(key: string, clearSelection?: boolean); + selectToIndex(index: number, clearSelection?: boolean); // Toggle helpers. From 9e4126cb21045931be472b5bfa770e3212cb4322 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Sun, 21 Aug 2016 18:52:14 -0700 Subject: [PATCH 4/6] Updates to docs. --- .../pages/SelectionPage/SelectionPage.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/demo/pages/SelectionPage/SelectionPage.tsx b/src/demo/pages/SelectionPage/SelectionPage.tsx index 31663d16513b62..3a8fe86954bc99 100644 --- a/src/demo/pages/SelectionPage/SelectionPage.tsx +++ b/src/demo/pages/SelectionPage/SelectionPage.tsx @@ -16,11 +16,28 @@ export class SelectionPage extends React.Component { It exposes methods for accessing the selection state given an item index. If the items change, it can resolve the selection if items move in the array.

-

- SelectionZone is a React component that handles selection change events. - It can help abstract range selection, unselecting/selecting items based on selection modes, - and handling common keystrokes like ctrl-A for select all and escape to clear selection. + +

SelectionZone is a React component that acts as a mediator between the Selection object and elements. By providing it the Selection instance and rendering content within it, you can have it manage clicking/focus/keyboarding from the DOM and translate into selection updates. You just need to provide the right data-selection-* attributes on elements within each row/tile to give SelectionZone a hint what the intent is.

+ +

SelectionZone also takes in an onItemInvoked callback for when items are invoked. Invoking occurs when a user double clicks a row, presses enter while focused on it, or clicks within an element marked by the data-selection-invoke attribute.

+ +

Available attributes:

+
    +
  • + data-selection-index: the index of the item being represented.This would go on the root of the tile/row. +
  • +
  • + data-selection-invoke: this boolean flag would be set on the element which should immediately invoke the item on click.There is also a nuanced behavior where we will clear selection and select the item if mousedown occurs on an unselected item. +
  • +
  • + data-selection-toggle: this boolean flag would be set on the element which should handle toggles.This could be a checkbox or a div. +
  • +
  • + data-selection-toggle-all: this boolean flag indicates that clicking it should toggle all selection. +
  • +
+

Examples

From 0517b34c08357afae9e48d36ef2bd2472990fbe6 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Sun, 21 Aug 2016 19:05:51 -0700 Subject: [PATCH 5/6] Removing unnecessary change. --- src/components/Link/Link.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index 667cae23b7f3c5..5b0e4eea697808 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -37,7 +37,7 @@ export class Link extends React.Component { { ...this.props as any } className={ css('ms-Link', className) } onClick={ this._onClick } > - { children } + { children } )); } From dd54a75724cf7c786702dbc7942d84b645458541 Mon Sep 17 00:00:00 2001 From: David Zearing Date: Sun, 21 Aug 2016 19:18:25 -0700 Subject: [PATCH 6/6] Updating detailslist basic example with correct selection example. --- .../DetailsListPage/examples/DetailsList.Basic.Example.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx index e63d19d9623002..14130c95d8f856 100644 --- a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx +++ b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx @@ -70,7 +70,7 @@ export class DetailsListBasicExample extends React.Component { case 0: return 'No items selected'; case 1: - return '1 item selected: ' + (this._selection.getItems()[0] as any).name; + return '1 item selected: ' + (this._selection.getSelection()[0] as any).name; default: return `${ selectionCount } items selected`; }