Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/Link/Link.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,9 @@ BUTTON.ms-Link {
display: inline;
padding: 0;
margin: 0;

width: inherit;
overflow: inherit;
text-overflow: inherit;
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
DetailsList,
MarqueeSelection,
Selection,
TextField
TextField,
Link
} from '../../../../index';
import { createListItems } from '../../../utilities/data';

Expand All @@ -19,6 +20,7 @@ export class DetailsListBasicExample extends React.Component<any, any> {

_items = _items || createListItems(500);

this._onRenderItemColumn = this._onRenderItemColumn.bind(this);
this._selection = new Selection({
onSelectionChanged: () => this.setState({ selectionDetails: this._getSelectionDetails() })
});
Expand All @@ -40,20 +42,35 @@ export class DetailsListBasicExample extends React.Component<any, any> {
onChanged={ text => this.setState({ items: text ? _items.filter(i => i.name.toLowerCase().indexOf(text) > -1) : _items }) }
/>
<MarqueeSelection selection={ this._selection }>
<DetailsList items={ items } initialFocusedIndex={ 0 } shouldApplyApplicationRole={ true } setKey='set' selection={ this._selection } />
<DetailsList
items={ items }
initialFocusedIndex={ 0 }
setKey='set'
selection={ this._selection }
onItemInvoked={ (item) => alert(`Item invoked: ${item.name}`) }
onRenderItemColumn={ this._onRenderItemColumn }
/>
</MarqueeSelection>
</div>
);
}

private _onRenderItemColumn(item, index, column) {
if (column.key === 'name') {
return <Link data-selection-invoke={ true }>{ item[column.key] }</Link>;
}

return item[column.key];
}

private _getSelectionDetails(): string {
let selectionCount = this._selection.getSelectedCount();

switch (selectionCount) {
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`;
}
Expand Down
25 changes: 21 additions & 4 deletions src/demo/pages/SelectionPage/SelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,28 @@ export class SelectionPage extends React.Component<any, any> {
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.
</p>
<p>
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.

<p>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.</p>

<p>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.
</p>

<p>Available attributes:</p>
<ul>
<li>
<b>data-selection-index</b>: the index of the item being represented.This would go on the root of the tile/row.
</li>
<li>
<b>data-selection-invoke</b>: 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.
</li>
<li>
<b>data-selection-toggle</b>: this boolean flag would be set on the element which should handle toggles.This could be a checkbox or a div.
</li>
<li>
<b>data-selection-toggle-all</b>: this boolean flag indicates that clicking it should toggle all selection.
</li>
</ul>

<h2 className='ms-font-xl'>Examples</h2>
<ExampleCard title='Basic Selection Example' code={ SelectionBasicExampleCode }>
<SelectionBasicExample />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export class SelectionBasicExample extends React.Component<any, ISelectionBasicE
<div className='ms-SelectionBasicExample'>
<CommandBar items={ this._getCommandItems() } />
<MarqueeSelection selection={ selection } isEnabled={ selectionMode === SelectionMode.multiple } >
<SelectionZone selection={ selection } selectionMode={ selectionMode } >
<SelectionZone
selection={ selection }
selectionMode={ selectionMode }
onItemInvoked={ (item) => alert('item invoked: ' + item.name) }>
{ items.map((item, index) => (
<SelectionItemExample
ref={ 'detailsGroup_' + index }
Expand Down Expand Up @@ -190,9 +193,9 @@ export class SelectionItemExample extends React.Component<ISelectionItemExampleP
return (
<div className='ms-SelectionItemExample' data-selection-index={ itemIndex }>
{ (selectionMode !== SelectionMode.none) && (
<button className='ms-SelectionItemExample-check' data-selection-toggle={ true } >
<div className='ms-SelectionItemExample-check' data-selection-toggle={ true } >
<Check isChecked={ isSelected } />
</button>
</div>
) }
<span className='ms-SelectionItemExample-name'>
{ item.name }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
border: none;
}

.ms-SelectionItemExample:hover {
background: #EEE;
}

.ms-SelectionItemExample-name {
display: inline-block;
overflow: hidden;
Expand Down
15 changes: 12 additions & 3 deletions src/utilities/selection/Selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || index >= this._items.length) {
return;
}

let isExempt = this._exemptedIndices[index];
let hasChanged = false;
let canSelect = !this._unselectableIndices[index];
Expand Down Expand Up @@ -210,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);
}
Expand Down
224 changes: 224 additions & 0 deletions src/utilities/selection/SelectionZone.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/* 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, expect } = chai;

import { SelectionZone, Selection, SelectionMode } from './index';
import { KeyCodes } from '../KeyCodes';

let _selection: Selection;
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(
<SelectionZone
selection={ _selection }
selectionMode={ selectionMode }
onItemInvoked={ (item) => { _onItemInvokeCalled++; _lastItemInvoked = item; } }>

<button id='toggleAll' data-selection-all-toggle={ true }>Toggle all selected</button>

<div id='surface0' data-selection-index='0'>
<button id='toggle0' data-selection-toggle={ true }>Toggle</button>
<button id='invoke0' data-selection-invoke={ true }>Invoke</button>
</div>

<div id='surface1' data-selection-index='1'>
<button id='toggle1' data-selection-toggle={ true }>Toggle</button>
<button id='invoke1' data-selection-invoke={ true }>Invoke</button>
</div>

<div id='invoke2' data-selection-index='2' data-selection-invoke={ true }>
<button id='toggle2' data-selection-toggle={ true }>Toggle</button>
</div>

<div id='surface3' data-selection-index='3'></div>

</SelectionZone>
);

_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(() => _initializeSelection());

it('toggles an item on click of toggle element', () => {
_simulateClick(_toggle0);
assert(_selection.isIndexSelected(0) === true, 'Index 0 not selected');
_simulateClick(_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 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);
expect(_selection.isIndexSelected(0)).equals(true, 'Index 0 not selected after mousedown');
expect(_selection.getSelectedCount()).equals(1, 'Only 1 item should be selected');
});

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);
expect(_selection.isAllSelected()).equals(true, 'Expecting all items to be selected');
});

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', () => {
_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);
_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');
});

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');
});

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');
});

it('does not select the row when clicking on a toggle within an invoke element', () => {
ReactTestUtils.Simulate.mouseDown(_toggle2);
expect(_selection.isIndexSelected(2)).equals(false, 'Item 2 should have been unselected');
});
});

function _simulateClick(el, eventData?: React.SyntheticEventData) {
ReactTestUtils.Simulate.mouseDown(el, eventData);
ReactTestUtils.Simulate.focus(el, eventData);
ReactTestUtils.Simulate.click(el, eventData);
}
Loading