diff --git a/src/components/Link/Link.scss b/src/components/Link/Link.scss index def5dffe1fef2..5658634d3f513 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/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx index 2d5fb94673b14..14130c95d8f85 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'; @@ -19,6 +20,7 @@ export class DetailsListBasicExample extends React.Component { _items = _items || createListItems(500); + this._onRenderItemColumn = this._onRenderItemColumn.bind(this); this._selection = new Selection({ onSelectionChanged: () => this.setState({ selectionDetails: this._getSelectionDetails() }) }); @@ -40,12 +42,27 @@ export class DetailsListBasicExample extends React.Component { onChanged={ text => this.setState({ items: text ? _items.filter(i => i.name.toLowerCase().indexOf(text) > -1) : _items }) } /> - + 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]; + } + private _getSelectionDetails(): string { let selectionCount = this._selection.getSelectedCount(); @@ -53,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`; } diff --git a/src/demo/pages/SelectionPage/SelectionPage.tsx b/src/demo/pages/SelectionPage/SelectionPage.tsx index 31663d16513b6..3a8fe86954bc9 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

diff --git a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx index c45961e30e081..8d40138849a2d 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 98b72f95a43b7..b370354beb6f8 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 ef070bccb877b..d78d309613b2d 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 || index >= this._items.length) { + return; + } + let isExempt = this._exemptedIndices[index]; let hasChanged = false; let canSelect = !this._unselectableIndices[index]; @@ -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); } diff --git a/src/utilities/selection/SelectionZone.test.tsx b/src/utilities/selection/SelectionZone.test.tsx new file mode 100644 index 0000000000000..86e4cc7fdb70b --- /dev/null +++ b/src/utilities/selection/SelectionZone.test.tsx @@ -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( + { _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(() => _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); +} diff --git a/src/utilities/selection/SelectionZone.tsx b/src/utilities/selection/SelectionZone.tsx index 55d6219940721..4d9e872fd8670 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 { @@ -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, - 'mousedown': this._onMouseDown - }); - - // 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,10 @@ export class SelectionZone extends React.Component {
@@ -111,161 +101,287 @@ 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 (isToggleModifierPressed) { + // set anchor only. + selection.setIndexSelected(index, selection.isIndexSelected(index), true); + } else { + this._onItemSurfaceClick(ev, index); + } + } + } - if (this._isShiftPressed && selectionMode === SelectionMode.multiple) { - if (!this._isCtrlPressed && !this._isMetaPressed) { - selection.setAllSelected(false); + private _onMouseDown(ev: React.MouseEvent) { + this._updateModifiers(ev); + + let target = ev.target as HTMLElement; + 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; } - selection.selectToIndex(index); - } else if (!this._isCtrlPressed && !this._isMetaPressed) { - selection.setAllSelected(false); - selection.setIndexSelected(index, true, true); } - selection.setChangeEvents(true); + target = target.parentElement; } - - this._hasClickedOnItem = false; } - private _onMouseDown(ev: MouseEvent) { - // We need to reset the key states for ctrl/meta/etc. - this._onKeyChangeCapture(ev as any); + private _onClick(ev: React.MouseEvent) { + this._updateModifiers(ev); + + let target = ev.target as HTMLElement; + let itemRoot = this._findItemRoot(target); + + 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; + } + } + + target = target.parentElement; + } + } + /** + * 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 } = this.props; - let index = this._getIndexFromElement(target, true); + 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; + } + + target = target.parentElement; + } - if (index >= 0 && selectionMode !== SelectionMode.none) { - this._hasClickedOnItem = true; + target = target.parentElement; } } - private _onClick(ev: React.MouseEvent) { + private _onKeyDown(ev: React.KeyboardEvent) { + 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 { selection, selectionMode } = this.props; + let isSelectAllKey = ev.which === KeyCodes.a && (this._isCtrlPressed || this._isMetaPressed); + let isClearSelectionKey = ev.which === KeyCodes.escape; - if (index >= 0 && selectionMode !== SelectionMode.none) { - let isSelected = selection.isIndexSelected(index); + // Ignore key downs from input elements. + if (this._isInputElement(target)) { + return; + } - // Disable change events. - selection.setChangeEvents(false); + // 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; + } - let isInvokable = this._hasAttribute(target, SELECTION_INVOKE_ATTRIBUTE_NAME); + // If escape is pressed, clear selection (if any are selected.) + if (isClearSelectionKey && selection.getSelectedCount() > 0) { + selection.setAllSelected(false); + ev.stopPropagation(); + ev.preventDefault(); + return; + } - 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); - } - selection.selectToIndex(index); - } else { - if (selectionMode === SelectionMode.single || !isToggleElement) { - selection.setAllSelected(false); + 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; } - selection.setIndexSelected(index, isToggleElement ? !isSelected : true, !ev.shiftKey); + 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) { @@ -283,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 5baf30f053352..bd94b7854ddf8 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.