Source: Component.js

import dom from './DOM.js'
import EventHandler from './EventHandler.js'

/**
`Component` contains the reactive logic for a responsive UI element.

It manages its DOM fragment based on events from DataModels and DataCollections as well as user input events.

It also tracks listeners so that on `cleanup` it can deregister itself to prevent object leaks.

Bink comes with a library of components so be sure to check the API docs before implementing a common basic view. They are also pretty good examples of how to write Components.

* @example <caption>A toggle with a label</caption>
* class BinaryComponent extends Component {
* 	constructor(dataObject=null, options={}) {
* 		super(dataObject, Object.assign(options, {
* 			label: null,
* 			dataField: null,
* 			dom: dom.span() // Default to a `span` DOM element
* 		})
* 		this.addClass('binary-component')
* 
* 		// Check that we have the info we need
* 		if (typeof this.options.dataField !== 'string') {
* 			throw new Error('BinaryComponent requires a `dataField` option')
* 		}
* 		if (this.dataObject instanceof DataModel === false) {
* 			throw new Error('BinaryComponent requires a DataModel')
* 		}
* 
* 		// Use a few sub-Components for UI
* 		this._labelComponent = new LabelComponent(undefined, {
* 				text: this.options.label || lt('No label')
* 		}).appendTo(this)
* 
* 		this._toggleComponent = new SwitchComponent( this.dataObject, {
* 			dataField: this.options.dataField
* 		}).appendTo(this)
* 	}
* }

*/
const Component = class extends EventHandler {
	/**
	@param {DataObject} [dataObject=null]
	@param {Object} [options={}]
	@param {HTMLElement} [options.dom=div] The HTML element that contains this Component's UI
	@param {string} [options.anchor=null] A URL that to which the document will traverse if the `dom` is clicked.
	@param {string} [options.name=null] If set, calls {@link Component.setName} with the value
	*/
	constructor(dataObject = null, options = {}) {
		super()

		/**
		`Component.dataObject` can be null or any class that extends {@link DataObject}.

		By default, that's {@link DataModel} and {@link DataCollection} but coders may create their own DataObject extension.

		@return {?DataObject}
		*/
		this.dataObject = dataObject

		/**
		@type {Object}
		@property {HTMLElement} [dom=null]
		*/
		this.options = Object.assign(
			{
				dom: null,
				anchor: null,
			},
			options
		)
		this.cleanedUp = false
		this._dom = this.options.dom || dom.div()
		if (typeof this._dom.addClass === 'undefined') {
			this._dom = dom.enhanceElement(this._dom)
		}
		this._anchor = this.options.anchor

		// See the Binder class below for info
		this._binder = new Binder(this)

		this.addClass('component')

		if (this.options.name) {
			this.setName(this.options.name)
		}

		if (this._anchor) {
			this.listenTo('click', this.dom, (ev) => {
				document.location.href = this._anchor
			})
		}
	}

	/**
	Called to dispose of any resources used by this component, including any listeners added via {@link Component.listenTo}.

	Extending classes *should* override and release any resources that they control.

	@return {Component} - the Component should not be used after this but the return is useful for chaining
	*/
	cleanup() {
		if (this.cleanedUp) return
		this.cleanedUp = true
		super.cleanup() // Cleans up the EventHandler
		this._binder.cleanup() // Cleans up bindings from `listenTo`
		return this
	}

	/**
	The DOM object that contains this Component's UI.

	You can override the default `dom` value from the constructor by passing an HTMLElement into the constructor's `options.dom`.

	@example <caption>Use it like any normal DOM element</caption>
	myComponent.dom.setAttribute('data-example', 'new-value')

	@return {HTMLElement}
	*/
	get dom() {
		return this._dom
	}

	/**
	A URL that to which the document will traverse if the `dom` is clicked, usually set via the constructor's `Option.anchor`

	@return {?string}
	*/
	get anchor() {
		return this._anchor
	}

	/**
	@param {string} [value=null] - A URL string to which the document will traverse if the `dom` is clicked
	*/
	set anchor(value) {
		this._anchor = value
	}

	/**
	Adds the childComponent's `dom` as a child of this Component's `dom`

	@param {Component} childComponent
	@return {Component} returns `this` (not the child component) for chaining
	*/
	append(childComponent) {
		this._dom.appendChild(childComponent.dom)
		return this
	}

	/**
	Removes the childComponent's `dom` from this Component's `dom`

	@param {Component} childComponent
	@param {boolean} [clean=true] if true, call {@link Component.cleanup} on the removed Component
	@return {Component} returns `this` (not the childComponent) for chaining
	*/
	removeComponent(childComponent, clean = true) {
		this._dom.removeChild(childComponent.dom)
		if (clean) childComponent.cleanup()
		return this
	}

	/**
	A handy method for quick creation and setting of a parent:

	@example
	this._fooComponent = new FooComponent().appendTo(parentComponent)

	@param {Component} parentComponent - The component to which `this` is appended
	@return {Component} returns `this` (not the parent component) for chaining
	*/
	appendTo(parentComponent) {
		parentComponent.append(this)
		return this
	}

	/**
	Sets the `name` attribute on this component's `dom.dataset` attribute.

	This is optional but it can be useful during debugging.

	@param {string} name
	@return {Component} returns `this` for chaining
	*/
	setName(name) {
		this._dom.setAttribute('data-name', name)
		return this
	}

	/**
	Add one or more classes to this component's dom `class` attribute without removing any existing classes.

	This is optional but the best practice is to add a unique name for classes that extend `Component`.

	@param {...string} classNames
	@return {Component} returns `this` for chaining
	*/
	addClass(...classNames) {
		this._dom.addClass(...classNames)
		return this
	}

	/**
	Remove one or more classes from this component's dom `class` attribute without changing other classes

	@param {...string} classNames
	@return {Component} returns `this` for chaining
	*/
	removeClass(...classNames) {
		this._dom.removeClass(...classNames)
		return this
	}

	/**
	Hides the dom by adding the `hidden` class

	@return {Component} returns `this` for chaining
	*/
	hide() {
		this.addClass('hidden')
		return this
	}

	/**
	Shows the dom by removing the `hidden` class

	@return {Component} returns `this` for chaining
	*/
	show() {
		this.removeClass('hidden')
		return this
	}

	/**
	Listen to a DOM, {@link Component}, or {@link DataObject} event.

	The nice thing about using `listenTo` instead of directly adding event listeners is that {@link Component.cleanup} will remove all of those listeners to avoid leaking this object.

	@example
	this.buttonDOM = dom.button('Click me')
	this.listenTo('click', this.buttonDOM, (domEvent) => { ... })

	@example
	this.buttonComponent = new ButtonComponent(...).appendTo(this)
	this.listenTo(ButtonComponent.ActivatedEvent, this.buttonComponent, (eventName) => { ... })

	@example
	this.exampleModel = new DataModel({ someField: 42 })
	this.listenTo('changed:someField', this.exampleModel, (eventName, ...params) => { ... })

	@param {string} eventName
	@param {HTMLElement|EventHandler} target
	@param {EventHandler~eventCallback} callback
	@param {boolean} [once=false] only listen to the first event, then unbind
	*/
	listenTo(eventName, target, callback, once = false) {
		this._binder.listenTo(eventName, target, callback, once)
	}

	/**
	@callback Component~textFormatter
	@param {string} value
	@returns string
	*/


	/**
	Sets the `innerText` of the target DOM element to the value of `dataModel.get(dataField, '')`, even as it changes.

	`formatter` defaults to the identity function but can be any function that accepts the value and returns a string.

	@example
	* this.component = new Component(
	* 	new DataModel({ description: 'Some example text' })
	* )
	* this.component.bindText(
	* 	'description', 		// dataField
	* 	this.component.dom,	// target
	* 	(value) => { return (typeof value === 'string') ? value.toUpperCase() : '' }
	* )
	// Now any changes to the DataModel's `description` field will be displayed by the component

	@param {string} dataField The name of the field to watch
	@param {HTMLElement} target The DOM element whose `innerText` will be manipulated
	@param {Component~textFormatter} [formatter=null]
	@param {DataModel} [dataModel=this.dataObject] defaults to this Component's `dataObject`
	*/
	bindText(dataField, target, formatter = null, dataModel = this.dataObject) {
		this._binder.bindText(dataField, target, formatter, dataModel)
	}

	/**
	Sets an attribute of the target DOM element to the value of dataModel.get(dataField), even as it changes.

	`formatter` defaults to the identity function but can be any function that accepts the value and returns a string.

	@example
	* this.component = new Component(
	* 	new DataModel({ isAmazing: false })
	* )
	* this.component.bindAttribute(
	* 	'isAmazing', 		// dataField
	* 	this.component.dom,	// target
	* 	'data-example',		// attributeName
	* 	(value) => { return value ? 'is-amazing' : 'not-amazing' }
	* )
	* // Now any changes to this.component.dataObject will change the `data-example` attribute
	* this.component.dataObject.set('isAmazing', true)

	@param {string} dataField - The name of the field on the `DataModel`
	@param {HTMLElement} target - The DOM element to manipulate 
	@param {string} attributeName - The DOM element's attribute to change
	@param {Component~textFormatter} [formatter=null] - defaults to identity (no change to field data)
	@param {DataModel} dataModel
	*/
	bindAttribute(dataField, target, attributeName, formatter = null, dataModel = this.dataObject) {
		this._binder.bindAttribute(dataField, target, attributeName, formatter, dataModel)
	}
}

/**
Binder listens for events on {@link EventHandler}s or DOM elements and changes characteristics of an HTMLElement or Component in response.
This is part of what makes a Component "reactive".
*/
const Binder = class {
	/**
	@param {Component} component
	*/
	constructor(component) {
		this._component = component
		this._bound
		s = [] // { callback, dataObject } to be unbound during cleanup
		this._eventCallbacks = [] // { callback, eventName, target } to be unregistered during cleanup
	}

	cleanup() {
		for (const bindInfo of this._boundCallbacks) {
			bindInfo.dataObject.removeListener(EventHandler.ALL_EVENTS, bindInfo.callback)
		}
		for (const info of this._eventCallbacks) {
			if (info.target instanceof EventHandler) {
				info.target.removeListener(info.eventName, info.callback)
			} else {
				info.target.removeEventListener(info.eventName, info.callback)
			}
		}
	}

	/**
	Listen to events from a DOM element or {@link EventHandler} in a way that they can be automatically cleaned up.

	Inside of a `Component`'s implementation you should use `listenTo` instead of directly listening using `HTMLElement.addListener` or {@link EventHandler.addListener}.

	The advantage of using `Component.listenTo` is that `Component` will keep track of these events and listener functions and then clean them up in {@link Component.cleanup}.

	@example <caption>Listen to DOM element events</caption>
	* class MyComponent extends Component {
	* 	constructor(dataObject=null, options={}) {
	* 		super(dataObject, options)
	*
	* 		// Listen to the Component's DOM fragment root:
	* 		this.listenTo('click', this.dom, (domClickEvent) => {
	* 			// Handle the entire Component's DOM click events
	* 		})
	*
	* 		// Listen to events on a child DOM element
	* 		const buttonEl = dom.button('Click me').appendTo(this.dom)
	* 		this.listenTo('click', buttonEl, (ev) => {
	* 			// Handle the button's DOM click event
	* 		})
	* 	}
	* }

	@example <caption>Listen to a sub-Component's events</caption>
	*class AnotherComponent extends Component {
	*	constructor(dataObject=null, options={}) {
	*		super(dataObject, options)
	*
	*		this.buttonComponent = new ButtonComponent(undefined,
	*			{ text: 'Click me' }
	*		).appendTo(this)
	*		this.listenTo(
	*			ButtonComponent.ActivatedEvent,
	*			this.buttonComponent,
	*			(eventName) => {
	*				// Handle the button's activated event
	*			}
	*		)
	*	}
	*}

	@param {string|Symbol} eventName - for DOM elements this will be a string like 'click' and for `Components` it will be a Symbol like `ButtonComponent.ActivatedEvent` 
	@param {HTMLElement|EventHandler} target - the object whose events should be listened to
	@param {EventHandler~eventCallback} callback - the function that is called when a matching event arrives
	@param {boolean} [once=false] - if true, the listener will be automatically removed when its first event arrives
	@return {Component} - return `this` for chaining
	*/
	listenTo(eventName, target, callback, once = false) {
		const info = {
			eventName: eventName,
			target: target,
			callback: callback,
			once: once,
		}
		if (target instanceof EventHandler) {
			target.addListener(eventName, info.callback)
		} else {
			target.addEventListener(eventName, info.callback)
		}
		this._eventCallbacks.push(info)
		return this
	}

	/**
	Sets the `innerText` of the target DOM element to the value of dataModel.get(dataField), even as it changes.

	`formatter` defaults to the identity function but can be any function that accepts the value and returns a string.

	@example
	* this.component = new Component(
	* 	new DataModel({ description: 'Some example text' })
	* )
	* this.component.bindText(
	* 	'description', 		// dataField
	* 	this.component.dom,	// target
	* 	(value) => { return (typeof value === 'string') ? value.toUpperCase() : '' }
	* )
	// Now any changes to the DataModel's `description` field will be displayed by the component

	@param {string} dataField The name of the field to watch
	@param {HTMLElement} target The DOM element whose `innerText` will be manipulated
	@param {Component~textFormatter} [formatter=null]
	@param {DataModel} [dataModel=this.dataObject] defaults to this Component's `dataObject`
	*/
	bindText(dataField, target, formatter = null, dataModel = this._component.dataObject) {
		if (formatter === null) {
			formatter = (value) => {
				if (value === null) return ''
				if (typeof value === 'string') return value
				return '' + value
			}
		}
		const callback = () => {
			const result = formatter(dataModel.get(dataField))
			target.innerText = typeof result === 'string' ? result : ''
		}
		dataModel.addListener(`changed:${dataField}`, callback)
		callback()
		this._boundCallbacks.push({
			callback: callback,
			dataObject: dataModel,
		})
	}

	/**
	Sets an attribute of the target DOM element to the value of dataModel.get(dataField), even as it changes.

	`formatter` defaults to the identity function but can be any function that accepts the value and returns a string.

	@example
	* this.component = new Component(
	* 	new DataModel({ isAmazing: false })
	* )
	* this.component.bindAttribute(
	* 	'isAmazing', 		// dataField
	* 	this.component.dom,	// target
	* 	'data-example',		// attributeName
	* 	(value) => { return value ? 'is-amazing' : 'not-amazing' }
	* )
	* // Now any changes to this.component.dataObject will change the `data-example` attribute
	* this.component.dataObject.set('isAmazing', true)

	@param {string} dataField - The name of the field on the `DataModel`
	@param {HTMLElement} target - The DOM element to manipulate 
	@param {string} attributeName - The DOM element's attribute to change
	@param {Component~textFormatter} [formatter=null] - defaults to identity (no change to field data)
	@param {DataModel} dataModel
	*/
	bindAttribute(dataField, target, attributeName, formatter = null, dataModel = this._component.dataObject) {
		if (formatter === null) {
			formatter = (value) => {
				if (value === null) return ''
				if (typeof value === 'string') return value
				return '' + value
			}
		}
		const callback = () => {
			target.setAttribute(attributeName, formatter(dataModel.get(dataField)))
		}
		dataModel.addListener(`changed:${dataField}`, callback)
		callback()
		this._boundCallbacks.push({
			callback: callback,
			dataObject: dataModel,
		})
	}
}

export default Component
export { Component }