define([ 'dojo/_base/declare', 'dojo/dom-class', 'dojo/on', 'dojo/has', 'dojo/aspect', './List', 'dojo/has!touch?./util/touch', 'dojo/query', 'dojo/_base/sniff', 'dojo/dom' // for has('css-user-select') in 1.8.2+ ], function (declare, domClass, on, has, aspect, List, touchUtil) { has.add('dom-comparedocumentposition', function (global, doc, element) { return !!element.compareDocumentPosition; }); // Add a feature test for the onselectstart event, which offers a more // graceful fallback solution than node.unselectable. has.add('dom-selectstart', typeof document.onselectstart !== 'undefined'); var ctrlEquiv = has('mac') ? 'metaKey' : 'ctrlKey', hasUserSelect = has('css-user-select'), hasPointer = has('pointer'), hasMSPointer = hasPointer && hasPointer.slice(0, 2) === 'MS', downType = hasPointer ? hasPointer + (hasMSPointer ? 'Down' : 'down') : 'mousedown', upType = hasPointer ? hasPointer + (hasMSPointer ? 'Up' : 'up') : 'mouseup'; if (hasUserSelect === 'WebkitUserSelect' && typeof document.documentElement.style.msUserSelect !== 'undefined') { // Edge defines both webkit and ms prefixes, rendering feature detects as brittle as UA sniffs... hasUserSelect = false; } function makeUnselectable(node, unselectable) { // Utility function used in fallback path for recursively setting unselectable var value = node.unselectable = unselectable ? 'on' : '', elements = node.getElementsByTagName('*'), i = elements.length; while (--i) { if (elements[i].tagName === 'INPUT' || elements[i].tagName === 'TEXTAREA') { continue; // Don't prevent text selection in text input fields. } elements[i].unselectable = value; } } function setSelectable(grid, selectable) { // Alternative version of dojo/dom.setSelectable based on feature detection. // For FF < 21, use -moz-none, which will respect -moz-user-select: text on // child elements (e.g. form inputs). In FF 21, none behaves the same. // See https://developer.mozilla.org/en-US/docs/CSS/user-select var node = grid.bodyNode, value = selectable ? 'text' : has('ff') < 21 ? '-moz-none' : 'none'; // In IE10+, -ms-user-select: none will block selection from starting within the // element, but will not block an existing selection from entering the element. // When using a modifier key, IE will select text inside of the element as well // as outside of the element, because it thinks the selection started outside. // Therefore, fall back to other means of blocking selection for IE10+. // Newer versions of Dojo do not even report msUserSelect (see https://github.com/dojo/dojo/commit/7ae2a43). if (hasUserSelect && hasUserSelect !== 'msUserSelect') { node.style[hasUserSelect] = value; } else if (has('dom-selectstart')) { // For browsers that don't support user-select but support selectstart (IE<10), // we can hook up an event handler as necessary. Since selectstart bubbles, // it will handle any child elements as well. // Note, however, that both this and the unselectable fallback below are // incapable of preventing text selection from outside the targeted node. if (!selectable && !grid._selectstartHandle) { grid._selectstartHandle = on(node, 'selectstart', function (evt) { var tag = evt.target && evt.target.tagName; // Prevent selection except where a text input field is involved. if (tag !== 'INPUT' && tag !== 'TEXTAREA') { evt.preventDefault(); } }); } else if (selectable && grid._selectstartHandle) { grid._selectstartHandle.remove(); delete grid._selectstartHandle; } } else { // For browsers that don't support either user-select or selectstart (Opera), // we need to resort to setting the unselectable attribute on all nodes // involved. Since this doesn't automatically apply to child nodes, we also // need to re-apply it whenever rows are rendered. makeUnselectable(node, !selectable); if (!selectable && !grid._unselectableHandle) { grid._unselectableHandle = aspect.after(grid, 'renderRow', function (row) { makeUnselectable(row, true); return row; }); } else if (selectable && grid._unselectableHandle) { grid._unselectableHandle.remove(); delete grid._unselectableHandle; } } } return declare(null, { // summary: // Add selection capabilities to a grid. The grid will have a selection property and // fire "dgrid-select" and "dgrid-deselect" events. // selectionDelegate: String // Selector to delegate to as target of selection events. selectionDelegate: '.dgrid-row', // selectionEvents: String|Function // Event (or comma-delimited events, or extension event) to listen on // to trigger select logic. selectionEvents: downType + ',' + upType + ',dgrid-cellfocusin', // selectionTouchEvents: String|Function // Event (or comma-delimited events, or extension event) to listen on // in addition to selectionEvents for touch devices. selectionTouchEvents: has('touch') ? touchUtil.tap : null, // deselectOnRefresh: Boolean // If true, the selection object will be cleared when refresh is called. deselectOnRefresh: true, // allowSelectAll: Boolean // If true, allow ctrl/cmd+A to select all rows. // Also consulted by the selector plugin for showing select-all checkbox. allowSelectAll: false, // selection: // An object where the property names correspond to // object ids and values are true or false depending on whether an item is selected selection: {}, // selectionMode: String // The selection mode to use, can be "none", "multiple", "single", or "extended". selectionMode: 'extended', // allowTextSelection: Boolean // Whether to still allow text within cells to be selected. The default // behavior is to allow text selection only when selectionMode is none; // setting this property to either true or false will explicitly set the // behavior regardless of selectionMode. allowTextSelection: undefined, // _selectionTargetType: String // Indicates the property added to emitted events for selected targets; // overridden in CellSelection _selectionTargetType: 'rows', create: function () { this.selection = {}; return this.inherited(arguments); }, postCreate: function () { this.inherited(arguments); this._initSelectionEvents(); // Force selectionMode setter to run var selectionMode = this.selectionMode; this.selectionMode = ''; this._setSelectionMode(selectionMode); }, destroy: function () { this.inherited(arguments); // Remove any extra handles added by Selection. if (this._selectstartHandle) { this._selectstartHandle.remove(); } if (this._unselectableHandle) { this._unselectableHandle.remove(); } if (this._removeDeselectSignals) { this._removeDeselectSignals(); } }, _setSelectionMode: function (mode) { // summary: // Updates selectionMode, resetting necessary variables. if (mode === this.selectionMode) { return; } // Start selection fresh when switching mode. this.clearSelection(); this.selectionMode = mode; // Compute name of selection handler for this mode once // (in the form of _fooSelectionHandler) this._selectionHandlerName = '_' + mode + 'SelectionHandler'; // Also re-run allowTextSelection setter in case it is in automatic mode. this._setAllowTextSelection(this.allowTextSelection); }, _setAllowTextSelection: function (allow) { if (typeof allow !== 'undefined') { setSelectable(this, allow); } else { setSelectable(this, this.selectionMode === 'none'); } this.allowTextSelection = allow; }, _handleSelect: function (event, target) { // Don't run if selection mode doesn't have a handler (incl. "none"), target can't be selected, // or if coming from a dgrid-cellfocusin from a mousedown if (!this[this._selectionHandlerName] || !this.allowSelect(this.row(target)) || (event.type === 'dgrid-cellfocusin' && event.parentType === 'mousedown') || (event.type === upType && target !== this._waitForMouseUp)) { return; } this._waitForMouseUp = null; this._selectionTriggerEvent = event; // Don't call select handler for ctrl+navigation if (!event.keyCode || !event.ctrlKey || event.keyCode === 32) { // If clicking a selected item, wait for mouseup so that drag n' drop // is possible without losing our selection if (!event.shiftKey && event.type === downType && this.isSelected(target)) { this._waitForMouseUp = target; } else { this[this._selectionHandlerName](event, target); } } this._selectionTriggerEvent = null; }, _singleSelectionHandler: function (event, target) { // summary: // Selection handler for "single" mode, where only one target may be // selected at a time. var ctrlKey = event.keyCode ? event.ctrlKey : event[ctrlEquiv]; if (this._lastSelected === target) { // Allow ctrl to toggle selection, even within single select mode. this.select(target, null, !ctrlKey || !this.isSelected(target)); } else { this.clearSelection(); this.select(target); this._lastSelected = target; } }, _multipleSelectionHandler: function (event, target) { // summary: // Selection handler for "multiple" mode, where shift can be held to // select ranges, ctrl/cmd can be held to toggle, and clicks/keystrokes // without modifier keys will add to the current selection. var lastRow = this._lastSelected, ctrlKey = event.keyCode ? event.ctrlKey : event[ctrlEquiv], value; if (!event.shiftKey) { // Toggle if ctrl is held; otherwise select value = ctrlKey ? null : true; lastRow = null; } this.select(target, lastRow, value); if (!lastRow) { // Update reference for potential subsequent shift+select // (current row was already selected above) this._lastSelected = target; } }, _extendedSelectionHandler: function (event, target) { // summary: // Selection handler for "extended" mode, which is like multiple mode // except that clicks/keystrokes without modifier keys will clear // the previous selection. // Clear selection first for right-clicks outside selection and non-ctrl-clicks; // otherwise, extended mode logic is identical to multiple mode if (event.button === 2 ? !this.isSelected(target) : !(event.keyCode ? event.ctrlKey : event[ctrlEquiv])) { this.clearSelection(null, true); } this._multipleSelectionHandler(event, target); }, _toggleSelectionHandler: function (event, target) { // summary: // Selection handler for "toggle" mode which simply toggles the selection // of the given target. Primarily useful for touch input. this.select(target, null, null); }, _initSelectionEvents: function () { // summary: // Performs first-time hookup of event handlers containing logic // required for selection to operate. var grid = this, contentNode = this.contentNode, selector = this.selectionDelegate; this._selectionEventQueues = { deselect: [], select: [] }; if (has('touch') && !has('pointer') && this.selectionTouchEvents) { // Listen for taps, and also for mouse/keyboard, making sure not // to trigger both for the same interaction on(contentNode, touchUtil.selector(selector, this.selectionTouchEvents), function (evt) { grid._handleSelect(evt, this); grid._ignoreMouseSelect = this; }); on(contentNode, on.selector(selector, this.selectionEvents), function (event) { if (grid._ignoreMouseSelect !== this) { grid._handleSelect(event, this); } else if (event.type === upType) { grid._ignoreMouseSelect = null; } }); } else { // Listen for mouse/keyboard actions that should cause selections on(contentNode, on.selector(selector, this.selectionEvents), function (event) { grid._handleSelect(event, this); }); } // Also hook up spacebar (for ctrl+space) if (this.addKeyHandler) { this.addKeyHandler(32, function (event) { grid._handleSelect(event, event.target); }); } // If allowSelectAll is true, bind ctrl/cmd+A to (de)select all rows, // unless the event was received from an editor component. // (Handler further checks against _allowSelectAll, which may be updated // if selectionMode is changed post-init.) if (this.allowSelectAll) { this.on('keydown', function (event) { if (event[ctrlEquiv] && event.keyCode === 65 && !/\bdgrid-input\b/.test(event.target.className)) { event.preventDefault(); grid[grid.allSelected ? 'clearSelection' : 'selectAll'](); } }); } // Update aspects if there is a collection change if (this._setCollection) { aspect.before(this, '_setCollection', function (collection) { grid._updateDeselectionAspect(collection); }); } this._updateDeselectionAspect(); }, _updateDeselectionAspect: function (collection) { // summary: // Hooks up logic to handle deselection of removed items. // Aspects to a trackable collection's notify method if applicable, // or to the list/grid's removeRow method otherwise. var self = this, signals; function ifSelected(rowArg, methodName) { // Calls a method if the row corresponding to the object is selected. var row = self.row(rowArg), selection = row && self.selection[row.id]; // Is the row currently in the selection list. if (selection) { self[methodName](row); } } // Remove anything previously configured if (this._removeDeselectSignals) { this._removeDeselectSignals(); } if (collection && collection.track && this._observeCollection) { signals = [ aspect.before(this, '_observeCollection', function (collection) { signals.push( collection.on('delete', function (event) { if (typeof event.index === 'undefined') { // Call deselect on the row if the object is being removed. This allows the // deselect event to reference the row element while it still exists in the DOM. ifSelected(event.id, 'deselect'); } }) ); }), aspect.after(this, '_observeCollection', function (collection) { signals.push( collection.on('update', function (event) { if (typeof event.index !== 'undefined') { // When List updates an item, the row element is removed and a new one inserted. // If at this point the object is still in grid.selection, // then call select on the row so the element's CSS is updated. ifSelected(collection.getIdentity(event.target), 'select'); } }) ); }, true) ]; } else { signals = [ aspect.before(this, 'removeRow', function (rowElement, preserveDom) { var row; if (!preserveDom) { row = this.row(rowElement); // if it is a real row removal for a selected item, deselect it if (row && (row.id in this.selection)) { this.deselect(row); } } }) ]; } this._removeDeselectSignals = function () { for (var i = signals.length; i--;) { signals[i].remove(); } signals = []; }; }, allowSelect: function () { // summary: // A method that can be overriden to determine whether or not a row (or // cell) can be selected. By default, all rows (or cells) are selectable. // target: Object // Row object (for Selection) or Cell object (for CellSelection) for the // row/cell in question return true; }, _fireSelectionEvent: function (type) { // summary: // Fires an event for the accumulated rows once a selection // operation is finished (whether singular or for a range) var queue = this._selectionEventQueues[type], triggerEvent = this._selectionTriggerEvent, eventObject; eventObject = { bubbles: true, grid: this }; if (triggerEvent) { eventObject.parentType = triggerEvent.type; } eventObject[this._selectionTargetType] = queue; // Clear the queue so that the next round of (de)selections starts anew this._selectionEventQueues[type] = []; on.emit(this.contentNode, 'dgrid-' + type, eventObject); }, _fireSelectionEvents: function () { var queues = this._selectionEventQueues, type; for (type in queues) { if (queues[type].length) { this._fireSelectionEvent(type); } } }, _select: function (row, toRow, value) { // summary: // Contains logic for determining whether to select targets, but // does not emit events. Called from select, deselect, selectAll, // and clearSelection. var selection, previousValue, element, toElement, direction; if (typeof value === 'undefined') { // default to true value = true; } if (!row.element) { row = this.row(row); } // Check whether we're allowed to select the given row before proceeding. // If a deselect operation is being performed, this check is skipped, // to avoid errors when changing column definitions, and since disabled // rows shouldn't ever be selected anyway. if (value === false || this.allowSelect(row)) { selection = this.selection; previousValue = !!selection[row.id]; if (value === null) { // indicates a toggle value = !previousValue; } element = row.element; if (!value && !this.allSelected) { delete this.selection[row.id]; } else { selection[row.id] = value; } if (element) { // add or remove classes as appropriate if (value) { domClass.add(element, 'dgrid-selected' + (this.addUiClasses ? ' ui-state-active' : '')); } else { domClass.remove(element, 'dgrid-selected ui-state-active'); } } if (value !== previousValue && element) { // add to the queue of row events this._selectionEventQueues[(value ? '' : 'de') + 'select'].push(row); } if (toRow) { if (!toRow.element) { toRow = this.row(toRow); } if (!toRow) { this._lastSelected = element; console.warn('The selection range has been reset because the ' + 'beginning of the selection is no longer in the DOM. ' + 'If you are using OnDemandList, you may wish to increase ' + 'farOffRemoval to avoid this, but note that keeping more nodes ' + 'in the DOM may impact performance.'); return; } toElement = toRow.element; if (toElement) { direction = this._determineSelectionDirection(element, toElement); if (!direction) { // The original element was actually replaced toElement = document.getElementById(toElement.id); direction = this._determineSelectionDirection(element, toElement); } while (row.element !== toElement && (row = this[direction](row))) { this._select(row, null, value); } } } } }, // Implement _determineSelectionDirection differently based on whether the // browser supports element.compareDocumentPosition; use sourceIndex for IE<9 _determineSelectionDirection: has('dom-comparedocumentposition') ? function (from, to) { var result = to.compareDocumentPosition(from); if (result & 1) { return false; // Out of document } return result === 2 ? 'down' : 'up'; } : function (from, to) { if (to.sourceIndex < 1) { return false; // Out of document } return to.sourceIndex > from.sourceIndex ? 'down' : 'up'; }, select: function (row, toRow, value) { // summary: // Selects or deselects the given row or range of rows. // row: Mixed // Row object (or something that can resolve to one) to (de)select // toRow: Mixed // If specified, the inclusive range between row and toRow will // be (de)selected // value: Boolean|Null // Whether to select (true/default), deselect (false), or toggle // (null) the row this._select(row, toRow, value); this._fireSelectionEvents(); }, deselect: function (row, toRow) { // summary: // Deselects the given row or range of rows. // row: Mixed // Row object (or something that can resolve to one) to deselect // toRow: Mixed // If specified, the inclusive range between row and toRow will // be deselected this.select(row, toRow, false); }, clearSelection: function (exceptId, dontResetLastSelected) { // summary: // Deselects any currently-selected items. // exceptId: Mixed? // If specified, the given id will not be deselected. this.allSelected = false; for (var id in this.selection) { if (exceptId !== id) { this._select(id, null, false); } } if (!dontResetLastSelected) { this._lastSelected = null; } this._fireSelectionEvents(); }, selectAll: function () { this.allSelected = true; this.selection = {}; // we do this to clear out pages from previous sorts for (var i in this._rowIdToObject) { var row = this.row(this._rowIdToObject[i]); this._select(row.id, null, true); } this._fireSelectionEvents(); }, isSelected: function (object) { // summary: // Returns true if the indicated row is selected. if (typeof object === 'undefined' || object === null) { return false; } if (!object.element) { object = this.row(object); } // First check whether the given row is indicated in the selection hash; // failing that, check if allSelected is true (testing against the // allowSelect method if possible) return (object.id in this.selection) ? !!this.selection[object.id] : this.allSelected && (!object.data || this.allowSelect(object)); }, refresh: function () { if (this.deselectOnRefresh) { this.clearSelection(); } this._lastSelected = null; return this.inherited(arguments); }, renderArray: function () { var rows = this.inherited(arguments), selection = this.selection, i, row, selected; for (i = 0; i < rows.length; i++) { row = this.row(rows[i]); selected = row.id in selection ? selection[row.id] : this.allSelected; if (selected) { this.select(row, null, selected); } } this._fireSelectionEvents(); return rows; } }); });