/**
* @module stb/ui/grid
* @author Stanislav Kalashnik <sk@infomir.eu>
* @license GNU GENERAL PUBLIC LICENSE Version 3
*/
'use strict';
var Component = require('../component'),
keys = require('../keys');
/**
* Mouse click event.
*
* @event module:stb/ui/grid~Grid#click:item
*
* @type {Object}
* @property {Element} $item clicked HTML item
* @property {Event} event click event data
*/
/**
* Base grid/table implementation.
*
* For navigation map implementation and tests see {@link https://gist.github.com/DarkPark/8c0c2926bfa234043ed1}.
*
* Each data cell can be either a primitive value or an object with these fields:
*
* Name | Description
* ---------|-------------
* value | actual cell value to render
* colSpan | amount of cells to merge horizontally
* rowSpan | amount of cells to merge vertically
* mark | is it necessary or not to render this cell as marked
* focus | is it necessary or not to render this cell as focused
* disable | is it necessary or not to set this cell as disabled
*
* @constructor
* @extends Component
*
* @param {Object} [config={}] init parameters (all inherited from the parent)
* @param {Array[]} [config.data=[]] component data to visualize
* @param {function} [config.render] method to build each grid cell content
* @param {function} [config.navigate] method to move focus according to pressed keys
* @param {boolean} [config.cycleX=true] allow or not to jump to the opposite side of line when there is nowhere to go next
* @param {boolean} [config.cycleY=true] allow or not to jump to the opposite side of column when there is nowhere to go next
*
* @fires module:stb/ui/grid~Grid#click:item
*
* @example
* var Grid = require('stb/ui/grid'),
* grid = new Grid({
* data: [
* [1, 2, 3, {value: '4;8;12;16', focus: true, rowSpan: 4}],
* [5, 6, 7],
* [9, 10, 11],
* [13, 14, {value: 15, disable: true}]
* ],
* render: function ( $item, data ) {
* $item.innerHTML = '<div>' + (data.value) + '</div>';
* },
* cycleX: false
* });
*/
function Grid ( config ) {
// current execution context
var self = this;
/**
* List of DOM elements representing the component cells.
* Necessary for navigation calculations.
*
* @type {Element[][]}
*/
this.map = [];
/**
* Link to the currently focused DOM element.
*
* @type {Element}
*/
this.$focusItem = null;
/**
* Component data to visualize.
*
* @type {Array[]}
*/
this.data = [];
/**
* Allow or not to jump to the opposite side of line when there is nowhere to go next.
*
* @type {boolean}
*/
this.cycleX = true;
/**
* Allow or not to jump to the opposite side of column when there is nowhere to go next.
*
* @type {boolean}
*/
this.cycleY = true;
/**
* Current navigation map horizontal position.
*
* @type {number}
*/
this.focusX = 0;
/**
* Current navigation map vertical position.
*
* @type {number}
*/
this.focusY = 0;
// sanitize
config = config || {};
// parent init
Component.call(this, config);
// correct CSS class names
this.$node.classList.add('grid');
// component setup
this.init(config);
// custom navigation method
if ( config.navigate !== undefined ) {
if ( DEBUG ) {
if ( typeof config.navigate !== 'function' ) { throw 'wrong config.navigate type'; }
}
// apply
this.navigate = config.navigate;
}
// navigation by keyboard
this.addListener('keydown', this.navigate);
// navigation by mouse
this.$body.addEventListener('mousewheel', function ( event ) {
// scrolling by Y axis
if ( event.wheelDeltaY ) {
self.move(event.wheelDeltaY > 0 ? keys.up : keys.down);
}
// scrolling by X axis
if ( event.wheelDeltaX ) {
self.move(event.wheelDeltaX > 0 ? keys.left : keys.right);
}
});
}
// inheritance
Grid.prototype = Object.create(Component.prototype);
Grid.prototype.constructor = Grid;
/**
* Fill the given cell with data.
* $item.data can contain the old data (from the previous render).
*
* @param {Element} $item item DOM link
* @param {*} data associated with this item data
*/
Grid.prototype.renderItemDefault = function ( $item, data ) {
if ( DEBUG ) {
if ( arguments.length !== 2 ) { throw 'wrong arguments number'; }
if ( !($item instanceof Element) ) { throw 'wrong $item type'; }
}
$item.innerText = data.value;
};
/**
* Method to build each grid cell content.
* Can be redefined to provide custom rendering.
*
* @type {function}
*/
Grid.prototype.renderItem = Grid.prototype.renderItemDefault;
/**
* Default method to move focus according to pressed keys.
*
* @param {Event} event generated event source of movement
*/
Grid.prototype.navigateDefault = function ( event ) {
switch ( event.code ) {
case keys.up:
case keys.down:
case keys.right:
case keys.left:
// cursor move only on arrow keys
this.move(event.code);
break;
case keys.ok:
// there are some listeners
if ( this.events['click:item'] !== undefined ) {
// notify listeners
this.emit('click:item', {$item: this.$focusItem, event: event});
}
break;
}
};
/**
* Current active method to move focus according to pressed keys.
* Can be redefined to provide custom navigation.
*
* @type {function}
*/
Grid.prototype.navigate = Grid.prototype.navigateDefault;
/**
* Make all the data items identical.
* Wrap to objects if necessary and add missing properties.
*
* @param {Array[]} data user 2-dimensional array
* @return {Array[]} reworked incoming data
*/
function normalize ( data ) {
var i, j, item;
if ( DEBUG ) {
if ( arguments.length !== 1 ) { throw 'wrong arguments number'; }
if ( !Array.isArray(data) ) { throw 'wrong data type'; }
}
// rows
for ( i = 0; i < data.length; i++ ) {
// cols
for ( j = 0; j < data[i].length; j++ ) {
// cell value
item = data[i][j];
// primitive value
if ( typeof item !== 'object' ) {
// wrap with defaults
item = data[i][j] = {
value: data[i][j],
colSpan: 1,
rowSpan: 1
};
} else {
// always at least one row/col
item.colSpan = item.colSpan || 1;
item.rowSpan = item.rowSpan || 1;
}
if ( DEBUG ) {
if ( !('value' in item) ) { throw 'field "value" is missing'; }
if ( Number(item.colSpan) !== item.colSpan ) { throw 'item.colSpan must be a number'; }
if ( Number(item.rowSpan) !== item.rowSpan ) { throw 'item.rowSpan must be a number'; }
if ( item.colSpan <= 0 ) { throw 'item.colSpan should be positive'; }
if ( item.rowSpan <= 0 ) { throw 'item.rowSpan should be positive'; }
if ( ('focus' in item) && Boolean(item.focus) !== item.focus ) { throw 'item.focus must be boolean'; }
if ( ('disable' in item) && Boolean(item.disable) !== item.disable ) { throw 'item.disable must be boolean'; }
}
}
}
return data;
}
/**
* Fill the given rectangle area with value.
*
* @param {Array[]} map link to navigation map
* @param {number} x current horizontal position
* @param {number} y current vertical position
* @param {number} dX amount of horizontal cell to fill
* @param {number} dY amount of vertical cell to fill
* @param {*} value filling data
*/
function fill ( map, x, y, dX, dY, value ) {
var i, j;
if ( DEBUG ) {
if ( arguments.length !== 6 ) { throw 'wrong arguments number'; }
if ( !Array.isArray(map) ) { throw 'wrong map type'; }
}
// rows
for ( i = y; i < y + dY; i++ ) {
// expand map rows
if ( map.length < i + 1 ) { map.push([]); }
// compensate long columns from previous rows
while ( map[i][x] !== undefined ) {
x++;
}
// cols
for ( j = x; j < x + dX; j++ ) {
// expand map row cols
if ( map[i].length < j + 1 ) { map[i].push(); }
// fill
map[i][j] = value;
// apply coordinates for future mouse clicks
if ( value.x === undefined ) { value.x = j; }
if ( value.y === undefined ) { value.y = i; }
}
}
}
/**
* Create a navigation map from incoming data.
*
* @param {Array[]} data user 2-dimensional array of objects
* @return {Array[]} navigation map
*/
function map ( data ) {
var result = [],
i, j, item;
if ( DEBUG ) {
if ( arguments.length !== 1 ) { throw 'wrong arguments number'; }
if ( !Array.isArray(data) ) { throw 'wrong data type'; }
}
// rows
for ( i = 0; i < data.length; i++ ) {
// cols
for ( j = 0; j < data[i].length; j++ ) {
// cell value
item = data[i][j];
// process a cell
fill(result, j, i, item.colSpan, item.rowSpan, item.$item);
// clear redundant info
delete item.$item;
}
}
return result;
}
/**
* Init or re-init of the component inner structures and HTML.
*
* @param {Object} config init parameters (subset of constructor config params)
*/
Grid.prototype.init = function ( config ) {
var self = this,
draw = false,
i, j,
$row, $item, $tbody, $focusItem,
itemData,
/**
* Cell mouse click handler.
*
* @param {Event} event click event data
*
* @this Element
*
* @fires module:stb/ui/grid~Grid#click:item
*/
onItemClick = function ( event ) {
// allow to accept focus
if ( this.data.disable !== true ) {
// visualize
self.focusItem(this);
// there are some listeners
if ( self.events['click:item'] !== undefined ) {
// notify listeners
self.emit('click:item', {$item: this, event: event});
}
}
};
if ( DEBUG ) {
if ( arguments.length !== 1 ) { throw 'wrong arguments number'; }
if ( typeof config !== 'object' ) { throw 'wrong config type'; }
}
// apply cycle behaviour
if ( config.cycleX !== undefined ) { this.cycleX = config.cycleX; }
if ( config.cycleY !== undefined ) { this.cycleY = config.cycleY; }
// apply data
if ( config.data !== undefined ) {
if ( DEBUG ) {
if ( !Array.isArray(config.data) || !Array.isArray(config.data[0]) ) { throw 'wrong config.data type'; }
}
// new data is different
if ( this.data !== config.data ) {
this.data = config.data;
// need to redraw table
draw = true;
}
}
// custom render method
if ( config.render !== undefined ) {
if ( DEBUG ) {
if ( typeof config.render !== 'function' ) { throw 'wrong config.render type'; }
}
// new render is different
if ( this.renderItem !== config.render ) {
this.renderItem = config.render;
// need to redraw table
draw = true;
}
}
if ( !draw ) {
// do not redraw table
return;
}
// export pointer to inner table
this.$table = document.createElement('table');
$tbody = document.createElement('tbody');
// prepare user data
this.data = normalize(this.data);
// rows
for ( i = 0; i < this.data.length; i++ ) {
// dom
$row = $tbody.insertRow();
// cols
for ( j = 0; j < this.data[i].length; j++ ) {
// dom
$item = $row.insertCell(-1);
// additional params
$item.className = 'item';
// shortcut
itemData = this.data[i][j];
// for map
itemData.$item = $item;
// merge columns
$item.colSpan = itemData.colSpan;
// merge rows
$item.rowSpan = itemData.rowSpan;
// active cell
if ( itemData.focus ) {
// store and clean
$focusItem = $item;
}
// disabled cell
if ( itemData.disable ) {
// apply CSS
$item.classList.add('disable');
}
// marked cell
if ( itemData.mark ) {
// apply CSS
$item.classList.add('mark');
}
// visualize
this.renderItem($item, itemData);
// save data link
$item.data = itemData;
// manual focusing
$item.addEventListener('click', onItemClick);
}
// row is ready
$tbody.appendChild($row);
}
// navigation map filling
this.map = map(this.data);
// clear all table
this.$body.innerText = null;
// everything is ready
this.$table.appendChild($tbody);
this.$body.appendChild(this.$table);
// apply focus
if ( $focusItem !== undefined ) {
// focus item was given in data
this.focusItem($focusItem);
} else {
// just the first cell
this.focusItem(this.map[0][0]);
}
};
/**
* Move focus to the given direction.
*
* @param {number} direction arrow key code
*
* @fires module:stb/ui/grid~Grid#cycle
* @fires module:stb/ui/grid~Grid#overflow
*/
Grid.prototype.move = function ( direction ) {
var x = this.focusX,
y = this.focusY,
move = true,
overflow = false,
cycle = false;
if ( DEBUG ) {
if ( arguments.length !== 1 ) { throw 'wrong arguments number'; }
if ( Number(direction) !== direction ) { throw 'direction must be a number'; }
}
// shift till full stop
while ( move ) {
// arrow keys
switch ( direction ) {
case keys.up:
if ( y > 0 ) {
// can go one step up
y--;
} else {
if ( this.cycleY ) {
// jump to the last row
y = this.map.length - 1;
cycle = true;
} else {
// grid edge
overflow = true;
}
}
break;
case keys.down:
if ( y < this.map.length - 1 ) {
// can go one step down
y++;
} else {
if ( this.cycleY ) {
// jump to the first row
y = 0;
cycle = true;
} else {
// grid edge
overflow = true;
}
}
break;
case keys.right:
if ( x < this.map[y].length - 1 ) {
// can go one step right
x++;
} else {
if ( this.cycleX ) {
// jump to the first column
x = 0;
cycle = true;
} else {
// grid edge
overflow = true;
}
}
break;
case keys.left:
if ( x > 0 ) {
// can go one step left
x--;
} else {
if ( this.cycleX ) {
// jump to the last column
x = this.map[y].length - 1;
cycle = true;
} else {
// grid edge
overflow = true;
}
}
break;
}
// full cycle - has come to the start point
if ( x === this.focusX && y === this.focusY ) {
// full stop
move = false;
}
// focus item has changed and it's not disabled
if ( this.map[y][x] !== this.map[this.focusY][this.focusX] && this.map[y][x].data.disable !== true ) {
// full stop
move = false;
}
// the last cell in a row/col
if ( overflow ) {
// full stop
move = false;
// but it's disabled so need to go back
if ( this.map[y][x].data.disable === true ) {
// return to the start point
x = this.focusX;
y = this.focusY;
}
}
}
if ( cycle ) {
// there are some listeners
if ( this.events['cycle'] !== undefined ) {
/**
* Jump to the opposite side.
*
* @event module:stb/ui/grid~Grid#cycle
*
* @type {Object}
* @property {number} direction key code initiator of movement
*/
this.emit('cycle', {direction: direction});
}
}
if ( overflow ) {
// there are some listeners
if ( this.events['overflow'] !== undefined ) {
/**
* Attempt to go beyond the edge of the grid.
*
* @event module:stb/ui/grid~Grid#overflow
*
* @type {Object}
* @property {number} direction key code initiator of movement
*/
this.emit('overflow', {direction: direction});
}
}
// report
debug.info(this.focusX + ' : ' + x, 'X old/new');
debug.info(this.focusY + ' : ' + y, 'Y old/new');
debug.info(cycle, 'cycle');
debug.info(overflow, 'overflow');
this.focusItem(this.map[y][x]);
// correct coordinates
// focusItem set approximate values
this.focusX = x;
this.focusY = y;
};
/**
* Highlight the given DOM element as focused.
* Remove focus from the previously focused item.
*
* @param {Node|Element} $item element to focus
* @param {number} $item.x the item horizontal position
* @param {number} $item.y the item vertical position
*
* @return {boolean} operation status
*
* @fires module:stb/ui/grid~Grid#focus:item
* @fires module:stb/ui/grid~Grid#blur:item
*/
Grid.prototype.focusItem = function ( $item ) {
var $prev = this.$focusItem;
if ( DEBUG ) {
if ( arguments.length !== 1 ) { throw 'wrong arguments number'; }
}
// different element
if ( $item !== undefined && $prev !== $item && $item.data.disable !== true ) {
if ( DEBUG ) {
if ( !($item instanceof Element) ) { throw 'wrong $item type'; }
if ( $item.parentNode.parentNode.parentNode.parentNode !== this.$body ) { throw 'wrong $item parent element'; }
}
// some item is focused already
if ( $prev !== null ) {
if ( DEBUG ) {
if ( !($prev instanceof Element) ) { throw 'wrong $prev type'; }
}
// style
$prev.classList.remove('focus');
// there are some listeners
if ( this.events['blur:item'] !== undefined ) {
/**
* Remove focus from an element.
*
* @event module:stb/ui/grid~Grid#blur:item
*
* @type {Object}
* @property {Element} $item previously focused HTML element
*/
this.emit('blur:item', {$item: $prev});
}
}
// draft coordinates
this.focusX = $item.x;
this.focusY = $item.y;
// reassign
this.$focusItem = $item;
// correct CSS
$item.classList.add('focus');
// there are some listeners
if ( this.events['focus:item'] !== undefined ) {
/**
* Set focus to an element.
*
* @event module:stb/ui/grid~Grid#focus:item
*
* @type {Object}
* @property {Element} $prev old/previous focused HTML element
* @property {Element} $curr new/current focused HTML element
*/
this.emit('focus:item', {$prev: $prev, $curr: $item});
}
return true;
}
// nothing was done
return false;
};
/**
* Set item state and appearance as marked.
*
* @param {Node|Element} $item element to focus
* @param {boolean} state true - marked, false - not marked
*/
Grid.prototype.markItem = function ( $item, state ) {
if ( DEBUG ) {
if ( arguments.length !== 2 ) { throw 'wrong arguments number'; }
if ( !($item instanceof Element) ) { throw 'wrong $item type'; }
if ( $item.parentNode.parentNode.parentNode.parentNode !== this.$body ) { throw 'wrong $item parent element'; }
if ( Boolean(state) !== state ) { throw 'state must be boolean'; }
}
// correct CSS
if ( state ) {
$item.classList.add('mark');
} else {
$item.classList.remove('mark');
}
// apply flag
$item.data.mark = state;
};
// public
module.exports = Grid;