/**
* @module stb/component
* @author Stanislav Kalashnik <sk@infomir.eu>
* @license GNU GENERAL PUBLIC LICENSE Version 3
*/
'use strict';
var Emitter = require('./emitter'),
router = require('./router'),
counter = 0;
/**
* Base component implementation.
*
* Visual element that can handle sub-components.
* Each component has a DOM element container $node with a set of classes:
* "component" and some specific component class names depending on the hierarchy, for example "page".
* Each component has a unique ID given either from $node.id or from data.id. If not given will generate automatically.
*
* @constructor
* @extends Emitter
*
* @param {Object} [config={}] init parameters
* @param {Element} [config.id] component unique identifier (generated if not set)
* @param {Element} [config.$node] DOM element/fragment to be a component outer container
* @param {Element} [config.$body] DOM element/fragment to be a component inner container (by default is the same as $node)
* @param {Element} [config.$content] DOM element/fragment to be appended to the $body
* @param {Component} [config.parent] link to the parent component which has this component as a child
* @param {Array.<Component>} [config.children=[]] list of components in this component
* @param {Object.<string, function>} [config.events={}] list of event callbacks
* @param {boolean} [config.visible=true] component initial visibility state flag
* @param {boolean} [config.focusable=true] component can accept focus or not
*
* @fires module:stb/component~Component#click
*
* @example
* var component = new Component({
* $node: document.getElementById(id),
* events: {
* click: function () { ... }
* }
* });
* component.add( ... );
* component.focus();
*/
function Component ( config ) {
// current execution context
var self = this;
/**
* Component visibility state flag.
*
* @readonly
* @type {boolean}
*/
this.visible = true;
/**
* Component can accept focus or not.
*
* @type {boolean}
*/
this.focusable = true;
/**
* DOM outer handle.
*
* @type {Element}
*/
this.$node = null;
/**
* DOM inner handle.
* In simple cases is the same as $node.
*
* @type {Element}
*/
this.$body = null;
if ( DEBUG ) {
/**
* Link to the page owner component.
* It can differ from the direct parent.
* Should be used only in debug.
*
* @type {Page}
*/
//this.page = null;
}
/**
* Link to the parent component which has this component as a child.
*
* @type {Component}
*/
this.parent = null;
/**
* List of all children components.
*
* @type {Component[]}
*/
this.children = [];
// sanitize
config = config || {};
if ( DEBUG ) {
if ( typeof config !== 'object' ) { throw 'wrong config type'; }
}
// parent init
Emitter.call(this, config.data);
// outer handle
if ( config.$node !== undefined ) {
if ( DEBUG ) {
if ( !(config.$node instanceof Element) ) { throw 'wrong config.$node type'; }
}
// apply
this.$node = config.$node;
} else {
// empty div in case nothing is given
this.$node = document.createElement('div');
}
// inner handle
if ( config.$body !== undefined ) {
if ( DEBUG ) {
if ( !(config.$body instanceof Element) ) { throw 'wrong config.$body type'; }
}
// apply
this.$body = config.$body;
} else {
// inner and outer handlers are identical
this.$body = this.$node;
}
// inject given content into inner component part
if ( config.$content !== undefined ) {
if ( DEBUG ) {
if ( !(config.$content instanceof Element) ) { throw 'wrong config.$content type'; }
}
// apply
this.$body.appendChild(config.$content);
}
// correct CSS class names
this.$node.classList.add('component');
// apply hierarchy
if ( config.parent !== undefined ) {
if ( DEBUG ) {
if ( !(config.parent instanceof Component) ) { throw 'wrong config.parent type'; }
}
// apply
config.parent.add(this);
}
// set link to the page owner component
//if ( config.page !== undefined ) {
// if ( DEBUG ) {
// if ( !(config.page instanceof Component) ) { throw 'wrong config.page type'; }
// }
// // apply
// this.page = config.page;
//}
// apply given visibility
if ( config.visible === false ) {
// default state is visible
this.hide();
}
// can't accept focus
if ( config.focusable === false ) {
this.focusable = false;
}
// apply given events
if ( config.events !== undefined ) {
// no need in assert here (it is done inside the addListeners)
this.addListeners(config.events);
}
// apply component id if given, generate otherwise
this.id = config.id || this.$node.id || 'id' + counter++;
// apply the given children components
if ( config.children ) {
if ( DEBUG ) {
if ( !Array.isArray(config.children) ) { throw 'wrong config.children type'; }
}
// apply
this.add.apply(this, config.children);
}
// component activation by mouse
this.$node.addEventListener('click', function ( event ) {
// left mouse button
if ( event.button === 0 ) {
// activate if possible
self.focus();
// there are some listeners
if ( self.events['click'] !== undefined ) {
/**
* Mouse click event.
*
* @event module:stb/component~Component#click
*
* @type {Object}
* @property {Event} event click event data
*/
self.emit('click', {event: event});
}
// not prevented
//if ( !event.stop ) {
// // activate if possible
// self.focus();
//}
}
if ( DEBUG ) {
// middle mouse button
if ( event.button === 1 ) {
debug.inspect(self);
debug.log('this component is now available by window.link');
window.link = self;
}
}
event.stopPropagation();
});
if ( DEBUG ) {
// expose a link
this.$node.component = this.$body.component = this;
this.$node.title = 'component ' + this.constructor.name + '.' + this.id + ' (outer)';
this.$body.title = 'component ' + this.constructor.name + '.' + this.id + ' (inner)';
}
// @todo remove or implement
// navigation by keyboard
//this.addListener('keydown', this.navigateDefault);
}
// inheritance
Component.prototype = Object.create(Emitter.prototype);
Component.prototype.constructor = Component;
/**
* Default method to move focus according to pressed keys.
*
* @todo remove or implement
*
* @param {Event} event generated event source of movement
*/
/*Component.prototype.navigateDefault = function ( event ) {
switch ( event.code ) {
case keys.up:
case keys.down:
case keys.right:
case keys.left:
// notify listeners
this.emit('overflow');
break;
}
};*/
/**
* Current active method to move focus according to pressed keys.
* Can be redefined to provide custom navigation.
*
* @todo remove or implement
*
* @type {function}
*/
/*Component.prototype.navigate = Component.prototype.navigateDefault;*/
/**
* Add a new component as a child.
*
* @param {...Component} [child] variable number of elements to append
*
* @files Component#add
*
* @example
* panel.add(
* new Button( ... ),
* new Button( ... )
* );
*/
Component.prototype.add = function ( child ) {
var i;
// walk through all the given elements
for ( i = 0; i < arguments.length; i++ ) {
child = arguments[i];
if ( DEBUG ) {
if ( !(child instanceof Component) ) { throw 'wrong child type'; }
}
// apply
this.children.push(child);
child.parent = this;
//if ( DEBUG ) {
// // apply page for this and all children recursively
// child.setPage(this.page);
//}
// correct DOM parent/child connection if necessary
if ( child.$node !== undefined && child.$node.parentNode === null ) {
this.$body.appendChild(child.$node);
}
// there are some listeners
if ( this.events['add'] !== undefined ) {
/**
* A child component is added.
*
* @event module:stb/component~Component#add
*
* @type {Object}
* @property {Component} child new component added
*/
this.emit('add', {item: child});
}
debug.log('component ' + this.constructor.name + '.' + this.id + ' new child: ' + child.constructor.name + '.' + child.id);
}
};
//if ( DEBUG ) {
// Component.prototype.setPage = function ( page ) {
// this.page = page;
//
// this.children.forEach(function ( child ) {
// child.setPage(page);
// });
// };
//}
/**
* Delete this component and clear all associated events.
*
* @fires module:stb/component~Component#remove
*/
Component.prototype.remove = function () {
var page = router.current;
// really inserted somewhere
if ( this.parent ) {
if ( DEBUG ) {
if ( !(this.parent instanceof Component) ) { throw 'wrong this.parent type'; }
}
// active at the moment
if ( page.activeComponent === this ) {
this.blur();
this.parent.focus();
}
this.parent.children.splice(this.parent.children.indexOf(this), 1);
}
// remove all children
this.children.forEach(function ( child ) {
if ( DEBUG ) {
if ( !(child instanceof Component) ) { throw 'wrong child type'; }
}
child.remove();
});
this.removeAllListeners();
this.$node.parentNode.removeChild(this.$node);
// there are some listeners
if ( this.events['remove'] !== undefined ) {
/**
* Delete this component.
*
* @event module:stb/component~Component#remove
*/
this.emit('remove');
}
debug.log('component ' + this.constructor.name + '.' + this.id + ' remove', 'red');
};
/**
* Activate the component.
* Notify the owner-page and apply CSS class.
*
* @return {boolean} operation status
*
* @fires module:stb/component~Component#focus
*/
Component.prototype.focus = function () {
var activePage = router.current,
activeItem = activePage.activeComponent;
//if ( DEBUG ) {
// if ( this.page !== activePage ) {
// console.log(this, this.page, activePage);
// throw 'attempt to focus an invisible component';
// }
//}
// this is a visual component on a page
// not already focused and can accept focus
if ( this.focusable && this !== activeItem ) {
// notify the current active component
if ( activeItem ) { activeItem.blur(); }
/* eslint consistent-this: 0 */
// apply
activePage.activeComponent = activeItem = this;
activeItem.$node.classList.add('focus');
// there are some listeners
if ( activeItem.events['focus'] !== undefined ) {
/**
* Make this component focused.
*
* @event module:stb/component~Component#focus
*/
activeItem.emit('focus');
}
debug.log('component ' + this.constructor.name + '.' + this.id + ' focus');
return true;
}
// nothing was done
return false;
};
/**
* Remove focus.
* Change page.activeComponent and notify subscribers.
*
* @return {boolean} operation status
*
* @fires module:stb/component~Component#blur
*/
Component.prototype.blur = function () {
var activePage = router.current,
activeItem = activePage.activeComponent;
// this is the active component
if ( this === activeItem ) {
this.$node.classList.remove('focus');
activePage.activeComponent = null;
// there are some listeners
if ( this.events['blur'] !== undefined ) {
/**
* Remove focus from this component.
*
* @event module:stb/component~Component#blur
*/
this.emit('blur');
}
debug.log('component ' + this.constructor.name + '.' + this.id + ' blur', 'grey');
return true;
}
// nothing was done
return false;
};
/**
* Make the component visible and notify subscribers.
*
* @param {Object} data custom data which passed into handlers
* @return {boolean} operation status
*
* @fires module:stb/component~Component#show
*/
Component.prototype.show = function ( data ) {
// is it hidden
if ( !this.visible ) {
// correct style
this.$node.classList.remove('hidden');
// flag
this.visible = true;
// there are some listeners
if ( this.events['show'] !== undefined ) {
/**
* Make the component visible.
*
* @event module:stb/component~Component#show
*/
this.emit('show', data);
}
return true;
}
// nothing was done
return true;
};
/**
* Make the component hidden and notify subscribers.
*
* @return {boolean} operation status
*
* @fires module:stb/component~Component#hide
*/
Component.prototype.hide = function () {
// is it visible
if ( this.visible ) {
// correct style
this.$node.classList.add('hidden');
// flag
this.visible = false;
// there are some listeners
if ( this.events['hide'] !== undefined ) {
/**
* Make the component hidden.
*
* @event module:stb/component~Component#hide
*/
this.emit('hide');
}
return true;
}
// nothing was done
return true;
};
// public
module.exports = Component;