Datatable dropdown usability tweaks (#3980)

Adds a couple of usability tweaks to the dropdown cell type in the data table widget, to more closely mimic a native dropdown field.

Pressing the up or down arrow keys when the cell is focused but with the dropdown closed will select the previous or next item automatically and set it as the cell value. This does prevent the usual table function of going to the previous or next row when focused on a dropdown cell, but I think it's a worthwhile trade-off. When the dropdown is open, the up and down arrows work the same as previously implemented.

Typing out characters will initiate a search and select the first matching option automatically and set it as the cell value. For example, for the following options:

Apples
Oranges
Bananas
Typing out o and r on the keyboard will automatically select the Oranges option.

Credit to @bennothommo
This commit is contained in:
Ben Thomson 2018-12-16 23:30:23 +08:00 committed by Luke Towers
parent a11868169e
commit 14c4d1392e
5 changed files with 221 additions and 74 deletions

View File

@ -395,9 +395,7 @@ html.cssanimations .control-table td[data-column-type=dropdown] [data-view-conta
cursor: pointer;
outline: none;
}
.table-control-dropdown-list li:hover,
.table-control-dropdown-list li:focus,
.table-control-dropdown-list li.selected {
.table-control-dropdown-list li:focus {
background: #34495e;
color: white;
}

View File

@ -266,10 +266,8 @@ return
for(var i=0,len=this.options.columns.length;i<len;i++){var column=this.options.columns[i].key
this.cellProcessors[column].onClick(ev)}
var target=this.getEventTarget(ev,'TD')
if(!target)
return
if(target.tagName!='TD')
return
if(!target){this.unfocusTable();return;}
if(target.tagName!='TD'){this.unfocusTable();return;}
this.focusCell(target,true)}
Table.prototype.onKeydown=function(ev){if(ev.keyCode==65&&ev.altKey&&this.options.adding){if(!ev.shiftKey){this.addRecord('below')}
else{this.addRecord('above')}
@ -279,7 +277,7 @@ if(ev.keyCode==68&&ev.altKey&&this.options.deleting){this.deleteRecord()
this.stopEvent(ev)
return}
for(var i=0,len=this.options.columns.length;i<len;i++){var column=this.options.columns[i].key
this.cellProcessors[column].onKeyDown(ev)}
if(this.cellProcessors[column].onKeyDown(ev)===false){return}}
if(this.navigation.onKeydown(ev)===false){return}
if(this.search.onKeydown(ev)===false){return}}
Table.prototype.onFormSubmit=function(ev,data){if(data.handler==this.options.postbackHandlerName){this.unfocusTable()
@ -789,20 +787,26 @@ throw new Error("The $.oc.table namespace is not defined. Make sure that the tab
throw new Error("The $.oc.table.processor namespace is not defined. Make sure that the table.processor.base.js script is loaded.");var Base=$.oc.table.processor.base,BaseProto=Base.prototype
var DropdownProcessor=function(tableObj,columnName,columnConfiguration){this.itemListElement=null
this.cachedOptionPromises={}
this.searching=false
this.searchQuery=null
this.searchInterval=null
this.itemClickHandler=this.onItemClick.bind(this)
this.itemKeyDownHandler=this.onItemKeyDown.bind(this)
this.itemMouseMoveHandler=this.onItemMouseMove.bind(this)
Base.call(this,tableObj,columnName,columnConfiguration)}
DropdownProcessor.prototype=Object.create(BaseProto)
DropdownProcessor.prototype.constructor=DropdownProcessor
DropdownProcessor.prototype.dispose=function(){this.unregisterListHandlers()
this.itemClickHandler=null
this.itemKeyDownHandler=null
this.itemMouseMoveHandler=null
this.itemListElement=null
this.cachedOptionPromises=null
BaseProto.dispose.call(this)}
DropdownProcessor.prototype.unregisterListHandlers=function(){if(this.itemListElement)
{this.itemListElement.removeEventListener('click',this.itemClickHandler)
this.itemListElement.removeEventListener('keydown',this.itemKeyDownHandler)}}
this.itemListElement.removeEventListener('keydown',this.itemKeyDownHandler)
this.itemListElement.removeEventListener('mousemove',this.itemMouseMoveHandler)}}
DropdownProcessor.prototype.renderCell=function(value,cellContentContainer){var viewContainer=this.createViewContainer(cellContentContainer,'...')
this.fetchOptions(cellContentContainer.parentNode,function renderCellFetchOptions(options){if(options[value]!==undefined)
viewContainer.textContent=options[value]
@ -825,6 +829,7 @@ self=this
this.itemListElement=document.createElement('div')
this.itemListElement.addEventListener('click',this.itemClickHandler)
this.itemListElement.addEventListener('keydown',this.itemKeyDownHandler)
this.itemListElement.addEventListener('mousemove',this.itemMouseMoveHandler)
this.itemListElement.setAttribute('class','table-control-dropdown-list')
this.itemListElement.style.width=cellContentContainer.offsetWidth+'px'
this.itemListElement.style.left=containerPosition.left+'px'
@ -866,37 +871,50 @@ return cachingKey}
DropdownProcessor.prototype.getAbsolutePosition=function(element){var top=document.body.scrollTop,left=0
do{top+=element.offsetTop||0;top-=element.scrollTop||0;left+=element.offsetLeft||0;element=element.offsetParent;}while(element)
return{top:top,left:left}}
DropdownProcessor.prototype.updateCellFromSelectedItem=function(selectedItem){this.tableObj.setCellValue(this.activeCell,selectedItem.getAttribute('data-value'))
this.setViewContainerValue(this.activeCell,selectedItem.textContent)}
DropdownProcessor.prototype.updateCellFromFocusedItem=function(){var focusedItem=this.findFocusedItem();this.setSelectedItem(focusedItem);}
DropdownProcessor.prototype.findSelectedItem=function(){if(this.itemListElement)
return this.itemListElement.querySelector('ul li.selected')
return null}
DropdownProcessor.prototype.setSelectedItem=function(item){if(!this.itemListElement)
return null;if(item.tagName=='LI'&&this.itemListElement.contains(item)){this.itemListElement.querySelectorAll('ul li').forEach(function(option){option.removeAttribute('class');});item.setAttribute('class','selected');}
this.tableObj.setCellValue(this.activeCell,item.getAttribute('data-value'))
this.setViewContainerValue(this.activeCell,item.textContent)}
DropdownProcessor.prototype.findFocusedItem=function(){if(this.itemListElement)
return this.itemListElement.querySelector('ul li:focus')
return null}
DropdownProcessor.prototype.onItemClick=function(ev){var target=this.tableObj.getEventTarget(ev)
if(target.tagName=='LI'){this.updateCellFromSelectedItem(target)
var selected=this.findSelectedItem()
if(selected)
selected.setAttribute('class','')
target.setAttribute('class','selected')
if(target.tagName=='LI'){target.focus();this.updateCellFromFocusedItem()
this.hideDropdown()}}
DropdownProcessor.prototype.onItemKeyDown=function(ev){if(!this.itemListElement)
return
if(ev.keyCode==40||ev.keyCode==38)
{var selected=this.findSelectedItem(),newSelectedItem=selected.nextElementSibling
{var focused=this.findFocusedItem(),newFocusedItem=focused.nextElementSibling
if(ev.keyCode==38)
newSelectedItem=selected.previousElementSibling
if(newSelectedItem){selected.setAttribute('class','')
newSelectedItem.setAttribute('class','selected')
newSelectedItem.focus()}
newFocusedItem=focused.previousElementSibling
if(newFocusedItem){newFocusedItem.focus()}
return}
if(ev.keyCode==13||ev.keyCode==32){this.updateCellFromSelectedItem(this.findSelectedItem())
if(ev.keyCode==13||ev.keyCode==32){this.updateCellFromFocusedItem()
this.hideDropdown()
return}
if(ev.keyCode==9){this.updateCellFromSelectedItem(this.findSelectedItem())
if(ev.keyCode==9){this.updateCellFromFocusedItem()
this.tableObj.navigation.navigateNext(ev)
this.tableObj.stopEvent(ev)}
if(ev.keyCode==27){this.hideDropdown()}}
DropdownProcessor.prototype.onKeyDown=function(ev){if(ev.keyCode==32)
this.showDropdown()}
this.tableObj.stopEvent(ev)
return}
if(ev.keyCode==27){this.hideDropdown()
return}
this.searchByTextInput(ev,true);}
DropdownProcessor.prototype.onItemMouseMove=function(ev){if(!this.itemListElement)
return
var target=this.tableObj.getEventTarget(ev)
if(target.tagName=='LI'){target.focus();}}
DropdownProcessor.prototype.onKeyDown=function(ev){if(!this.itemListElement)
return
if(ev.keyCode==32&&!this.searching){this.showDropdown()}else if(ev.keyCode==40||ev.keyCode==38){var selected=this.findSelectedItem(),newSelectedItem;if(!selected){if(ev.keyCode==38){return false}
newSelectedItem=this.itemListElement.querySelector('ul li:first-child')}else{newSelectedItem=selected.nextElementSibling
if(ev.keyCode==38)
newSelectedItem=selected.previousElementSibling}
if(newSelectedItem){this.setSelectedItem(newSelectedItem);}
return false}else{this.searchByTextInput(ev);}}
DropdownProcessor.prototype.onRowValueChanged=function(columnName,cellElement){if(!this.columnConfiguration.dependsOn)
return
var dependsOnColumn=false,dependsOn=this.columnConfiguration.dependsOn
@ -912,6 +930,12 @@ viewContainer=null})}
DropdownProcessor.prototype.elementBelongsToProcessor=function(element){if(!this.itemListElement)
return false
return this.tableObj.parentContainsElement(this.itemListElement,element)}
DropdownProcessor.prototype.searchByTextInput=function(ev,focusOnly){if(focusOnly===undefined){focusOnly=false;}
var character=ev.key;if(character.length===1||character==='Space'){if(!this.searching){this.searching=true;this.searchQuery='';}
this.searchQuery+=(character==='Space')?' ':character;var validItem=null;var query=this.searchQuery;this.itemListElement.querySelectorAll('ul li').forEach(function(item){if(validItem===null&&item.dataset.value&&item.dataset.value.toLowerCase().indexOf(query.toLowerCase())===0){validItem=item;}});if(validItem){if(focusOnly===true){validItem.focus();}else{this.setSelectedItem(validItem);}
if(this.searchInterval){clearTimeout(this.searchInterval);}
this.searchInterval=setTimeout(this.cancelTextSearch.bind(this),1000);}else{this.cancelTextSearch();}}}
DropdownProcessor.prototype.cancelTextSearch=function(){this.searching=false;this.searchQuery=null;this.searchInterval=null;}
$.oc.table.processor.dropdown=DropdownProcessor;}(window.jQuery);+function($){"use strict";if($.oc.table===undefined)
throw new Error("The $.oc.table namespace is not defined. Make sure that the table.js script is loaded.");if($.oc.table.processor===undefined)
throw new Error("The $.oc.table.processor namespace is not defined. Make sure that the table.processor.base.js script is loaded.");var Base=$.oc.table.processor.string,BaseProto=Base.prototype

View File

@ -48,7 +48,7 @@
this.dataTableContainer = null
// The key of the row which is being edited at the moment.
// This key corresponds the data source row key which
// This key corresponds the data source row key which
// uniquely identifies the row in the data set. When the
// table grid notices that a cell in another row is edited it commits
// the previously edited record to the data source.
@ -556,8 +556,8 @@
}
Table.prototype.addRecord = function(placement, noFocus) {
// If there is no active cell, or the pagination is enabled or
// row sorting is disabled, add the record to the bottom of
// If there is no active cell, or the pagination is enabled or
// row sorting is disabled, add the record to the bottom of
// the table (last page).
if (!this.activeCell || this.navigation.paginationEnabled() || !this.options.rowSorting)
@ -600,7 +600,7 @@
])
this.dataSource.createRecord(recordData, placement, relativeToKey,
this.navigation.getPageFirstRowOffset(),
this.navigation.getPageFirstRowOffset(),
this.options.recordsPerPage,
function onAddRecordDataTableSuccess(records, totalCount) {
self.buildDataTable(records, totalCount)
@ -655,7 +655,7 @@
else
self.navigation.focusCellInReplacedRow(currentRowIndex, currentCellIndex)
}
self = null
}
)
@ -742,11 +742,15 @@
var target = this.getEventTarget(ev, 'TD')
if (!target)
return
if (!target) {
this.unfocusTable();
return;
}
if (target.tagName != 'TD')
return
if (target.tagName != 'TD') {
this.unfocusTable();
return;
}
this.focusCell(target, true)
}
@ -777,7 +781,9 @@
for (var i = 0, len = this.options.columns.length; i < len; i++) {
var column = this.options.columns[i].key
this.cellProcessors[column].onKeyDown(ev)
if (this.cellProcessors[column].onKeyDown(ev) === false) {
return
}
}
if (this.navigation.onKeydown(ev) === false) {
@ -838,7 +844,7 @@
if (this.parentContainsElement(this.el, target))
return
// Request the active cell processor if the clicked
// Request the active cell processor if the clicked
// element belongs to any extra-table element created
// by the processor
@ -887,7 +893,7 @@
// Delete references to the control HTML elements.
// The script doesn't remove any DOM elements themselves.
// If it's needed it should be done by the outer script,
// we only make sure that the table widget doesn't hold
// we only make sure that the table widget doesn't hold
// references to the detached DOM tree so that the garbage
// collector can delete the elements if needed.
this.disposeScrollbar()
@ -901,7 +907,7 @@
}
/*
* Updates row values in the table.
* Updates row values in the table.
* rowIndex is an integer value containing the row index on the current page.
* The rowValues should be a hash object containing only changed
* columns.
@ -999,7 +1005,7 @@
if (el.classList)
return el.classList.contains(className);
return new RegExp('(^| )' + className + '( |$)', 'gi').test(el.className);
}
@ -1122,7 +1128,7 @@
var old = $.fn.table
$.fn.table = function (option) {
var args = Array.prototype.slice.call(arguments, 1),
var args = Array.prototype.slice.call(arguments, 1),
result = undefined
this.each(function () {
@ -1156,4 +1162,4 @@
$('div[data-control=table]').table()
})
}(window.jQuery);
}(window.jQuery);

View File

@ -31,10 +31,14 @@
this.itemListElement = null
this.cachedOptionPromises = {}
this.searching = false
this.searchQuery = null
this.searchInterval = null
// Event handlers
this.itemClickHandler = this.onItemClick.bind(this)
this.itemKeyDownHandler = this.onItemKeyDown.bind(this)
this.itemMouseMoveHandler = this.onItemMouseMove.bind(this)
//
// Parent constructor
@ -50,6 +54,7 @@
this.unregisterListHandlers()
this.itemClickHandler = null
this.itemKeyDownHandler = null
this.itemMouseMoveHandler = null
this.itemListElement = null
this.cachedOptionPromises = null
BaseProto.dispose.call(this)
@ -64,6 +69,7 @@
// body, not to the table.
this.itemListElement.removeEventListener('click', this.itemClickHandler)
this.itemListElement.removeEventListener('keydown', this.itemKeyDownHandler)
this.itemListElement.removeEventListener('mousemove', this.itemMouseMoveHandler)
}
}
@ -116,7 +122,7 @@
}
DropdownProcessor.prototype.buildEditor = function(cellElement, cellContentContainer, isClick) {
// Create the select control
// Create the select control
var currentValue = this.tableObj.getCellValue(cellElement),
containerPosition = this.getAbsolutePosition(cellContentContainer)
self = this
@ -125,6 +131,7 @@
this.itemListElement.addEventListener('click', this.itemClickHandler)
this.itemListElement.addEventListener('keydown', this.itemKeyDownHandler)
this.itemListElement.addEventListener('mousemove', this.itemMouseMoveHandler)
this.itemListElement.setAttribute('class', 'table-control-dropdown-list')
this.itemListElement.style.width = cellContentContainer.offsetWidth + 'px'
@ -133,7 +140,7 @@
this.fetchOptions(cellElement, function renderCellFetchOptions(options) {
var listElement = document.createElement('ul')
for (var value in options) {
var itemElement = document.createElement('li')
itemElement.setAttribute('data-value', value)
@ -197,7 +204,7 @@
}
else {
// If options are not provided and not found in the cache,
// request them from the server. For dependent drop-downs
// request them from the server. For dependent drop-downs
// the caching key contains the master column values.
var row = cellElement.parentNode,
@ -263,9 +270,9 @@
}
}
DropdownProcessor.prototype.updateCellFromSelectedItem = function(selectedItem) {
this.tableObj.setCellValue(this.activeCell, selectedItem.getAttribute('data-value'))
this.setViewContainerValue(this.activeCell, selectedItem.textContent)
DropdownProcessor.prototype.updateCellFromFocusedItem = function() {
var focusedItem = this.findFocusedItem();
this.setSelectedItem(focusedItem);
}
DropdownProcessor.prototype.findSelectedItem = function() {
@ -275,17 +282,34 @@
return null
}
DropdownProcessor.prototype.setSelectedItem = function(item) {
if (!this.itemListElement)
return null;
if (item.tagName == 'LI' && this.itemListElement.contains(item)) {
this.itemListElement.querySelectorAll('ul li').forEach(function (option) {
option.removeAttribute('class');
});
item.setAttribute('class', 'selected');
}
this.tableObj.setCellValue(this.activeCell, item.getAttribute('data-value'))
this.setViewContainerValue(this.activeCell, item.textContent)
}
DropdownProcessor.prototype.findFocusedItem = function() {
if (this.itemListElement)
return this.itemListElement.querySelector('ul li:focus')
return null
}
DropdownProcessor.prototype.onItemClick = function(ev) {
var target = this.tableObj.getEventTarget(ev)
if (target.tagName == 'LI') {
this.updateCellFromSelectedItem(target)
var selected = this.findSelectedItem()
if (selected)
selected.setAttribute('class', '')
target.setAttribute('class', 'selected')
target.focus();
this.updateCellFromFocusedItem()
this.hideDropdown()
}
}
@ -297,16 +321,14 @@
if (ev.keyCode == 40 || ev.keyCode == 38)
{
// Up or down keys - find previous/next list item and select it
var selected = this.findSelectedItem(),
newSelectedItem = selected.nextElementSibling
var focused = this.findFocusedItem(),
newFocusedItem = focused.nextElementSibling
if (ev.keyCode == 38)
newSelectedItem = selected.previousElementSibling
newFocusedItem = focused.previousElementSibling
if (newSelectedItem) {
selected.setAttribute('class', '')
newSelectedItem.setAttribute('class', 'selected')
newSelectedItem.focus()
if (newFocusedItem) {
newFocusedItem.focus()
}
return
@ -314,22 +336,39 @@
if (ev.keyCode == 13 || ev.keyCode == 32) {
// Return or space keys - update the selected value and hide the editor
this.updateCellFromSelectedItem(this.findSelectedItem())
this.updateCellFromFocusedItem()
this.hideDropdown()
return
}
if (ev.keyCode == 9) {
// Tab - update the selected value and pass control to the table navigation
this.updateCellFromSelectedItem(this.findSelectedItem())
this.updateCellFromFocusedItem()
this.tableObj.navigation.navigateNext(ev)
this.tableObj.stopEvent(ev)
return
}
if (ev.keyCode == 27) {
// Esc - hide the drop-down
this.hideDropdown()
return
}
this.searchByTextInput(ev, true);
}
/*
* Event handler for mouse movements over options in the dropdown menu
*/
DropdownProcessor.prototype.onItemMouseMove = function(ev) {
if (!this.itemListElement)
return
var target = this.tableObj.getEventTarget(ev)
if (target.tagName == 'LI') {
target.focus();
}
}
@ -338,8 +377,36 @@
* for all processors.
*/
DropdownProcessor.prototype.onKeyDown = function(ev) {
if (ev.keyCode == 32)
if (!this.itemListElement)
return
if (ev.keyCode == 32 && !this.searching) { // Spacebar
this.showDropdown()
} else if (ev.keyCode == 40 || ev.keyCode == 38) { // Up and down arrow keys
var selected = this.findSelectedItem(),
newSelectedItem;
if (!selected) {
if (ev.keyCode == 38) {
// Only show an initial item when the down array key is pressed
return false
}
newSelectedItem = this.itemListElement.querySelector('ul li:first-child')
} else {
newSelectedItem = selected.nextElementSibling
if (ev.keyCode == 38)
newSelectedItem = selected.previousElementSibling
}
if (newSelectedItem) {
this.setSelectedItem(newSelectedItem);
}
return false // Stop propogation of event
} else {
this.searchByTextInput(ev);
}
}
/*
@ -386,8 +453,8 @@
}
/*
* Determines whether the specified element is some element created by the
* processor.
* Determines whether the specified element is some element created by the
* processor.
*/
DropdownProcessor.prototype.elementBelongsToProcessor = function(element) {
if (!this.itemListElement)
@ -396,5 +463,59 @@
return this.tableObj.parentContainsElement(this.itemListElement, element)
}
/*
* Provides auto-complete like functionality for typing in a query and selecting
* a matching list option
*/
DropdownProcessor.prototype.searchByTextInput = function(ev, focusOnly) {
if (focusOnly === undefined) {
focusOnly = false;
}
var character = ev.key;
if (character.length === 1 || character === 'Space') {
if (!this.searching) {
this.searching = true;
this.searchQuery = '';
}
this.searchQuery += (character === 'Space') ? ' ' : character;
// Search for a valid option in dropdown
var validItem = null;
var query = this.searchQuery;
this.itemListElement.querySelectorAll('ul li').forEach(function(item) {
if (validItem === null && item.dataset.value && item.dataset.value.toLowerCase().indexOf(query.toLowerCase()) === 0) {
validItem = item;
}
});
if (validItem) {
// If a valid item is found, select item and allow for fine-tuning the search query
if (focusOnly === true) {
validItem.focus();
} else {
this.setSelectedItem(validItem);
}
if (this.searchInterval) {
clearTimeout(this.searchInterval);
}
this.searchInterval = setTimeout(this.cancelTextSearch.bind(this), 1000);
} else {
this.cancelTextSearch();
}
}
}
DropdownProcessor.prototype.cancelTextSearch = function() {
this.searching = false;
this.searchQuery = null;
this.searchInterval = null;
}
$.oc.table.processor.dropdown = DropdownProcessor;
}(window.jQuery);
}(window.jQuery);

View File

@ -476,11 +476,9 @@ html.cssanimations {
cursor: pointer;
outline: none;
&:hover,
&:focus,
&.selected {
&:focus {
background: @color-focus;
color: white;
}
}
}
}