src/tree.js
import Events from 'eventemitter3'
import clicked from 'clicked'
import { Input } from './input'
import { defaults, styleDefaults } from './defaults'
import * as utils from './utils'
import { icons } from './icons'
export class Tree extends Events {
/**
* Create Tree
* @param {TreeData} tree - data for tree (see readme for description)
* @param {object} [options]
* @param {(HTMLElement|string)} [options.element] if a string then document.querySelector(element); if empty, it creates an element
* @param {(HTMLElement|string)} [options.parent] appends the element to this parent (if string then document.querySelector(parent))
* @param {boolean} [options.move=true] drag tree to rearrange
* @param {boolean} [options.select=true] click to select node (if false then nodes are not selected and tree.selection is always null)
* @param {number} [options.indentation=20] number of pixels to indent for each level
* @param {number} [options.threshold=10] number of pixels to move to start a drag
* @param {number} [options.holdTime=2000] number of milliseconds to press and hold name before editing starts (set to 0 to disable)
* @param {boolean} [options.expandOnClick=true] expand and collapse node on click without drag except (will select before expanding if select=true)
* @param {number} [options.dragOpacity=0.75] opacity setting for dragged item
* @param {string} [options.prefixClassName=yy-tree] first part of className for all DOM objects (e.g., yy-tree, yy-tree-indicator)
* @param {boolean} [options.addStyles=true] attaches a style sheet with default and overridden styles; set to false to use your own stylesheet
* @param {object} [styles]
* @param {string[]} [styles.nameStyles] use these to override individual styles for the name (will be included in the attached stylesheet)
* @param {string[]} [styles.contentStyles] use these to override individual styles for the content (will be included in the attached stylesheet)
* @param {string[]} [styles.indicatorStyles] use these to override individual styles for the move-line indicator (will be included in the attached stylesheet)
* @param {string[]} [styles.selectedStyles] use these to override individual styles for the selected item (will be included in the attached stylesheet)
* @fires render
* @fires clicked
* @fires expand
* @fires collapse
* @fires name-change
* @fires move
* @fires move-pending
* @fires update
*/
constructor(tree, options, styles) {
super()
this._options = utils.options(options, defaults)
this._input = new Input(this)
if (typeof this._options.element === 'undefined') {
/**
* Main div holding tree
* @type {HTMLElement}
*/
this.element = document.createElement('div')
} else {
this.element = utils.el(this._options.element)
}
if (this._options.parent) {
utils.el(this._options.parent).appendChild(this.element)
}
this.element.classList.add(this.prefixClassName)
this.element.data = tree
if (this._options.addStyles !== false) {
this._addStyles(styles)
}
this.update()
}
/**
* Selected data
* @type {*}
*/
get selection() {
return this._selection.data
}
set selection(data) {
}
/**
* className's prefix (e.g., "yy-tree"-content)
* @type {string}
*/
get prefixClassName() {
return this._options.prefixClassName
}
set prefixClassName(value) {
if (value !== this._options.prefixClassName) {
this._options.prefixClassName = value
this.update()
}
}
/**
* indentation for tree
* @type {number}
*/
get indentation() {
return this._options.indentation
}
set indentation(value) {
if (value !== this._options.indentation) {
this._options.indentation = value
this._input._indicatorMarginLeft = value + 'px'
this.update()
}
}
/**
* number of milliseconds to press and hold name before editing starts (set to 0 to disable)
* @type {number}
*/
get holdTime() {
return this._options.holdTime
}
set holdTime(value) {
if (value !== this._options.holdTime) {
this._options.holdTime = value
}
}
/**
* whether tree may be rearranged
* @type {boolean}
*/
get move() {
return this._options.move
}
set move(value) {
this._options.move = value
}
/**
* expand and collapse node on click without drag except (will select before expanding if select=true)
* @type {boolean}
*/
get expandOnClick() {
return this._options.expandOnClick
}
set expandOnClick(value) {
this._options.expandOnClick = value
}
/**
* click to select node (if false then nodes are not selected and tree.selection is always null)
* @type {boolean}
*/
get select() {
return this._options.select
}
set select(value) {
this._options.select = value
}
/**
* opacity setting for dragged item
* @type {number}
*/
get dragOpacity() {
return this._options.dragOpacity
}
set dragOpacity(value) {
this._options.dragOpacity = value
}
_leaf(data, level) {
const leaf = utils.html({ className: `${this.prefixClassName}-leaf` })
leaf.isLeaf = true
leaf.data = data
leaf.content = utils.html({ parent: leaf, className: `${this.prefixClassName}-content` })
leaf.style.marginLeft = this.indentation + 'px'
leaf.icon = utils.html({
parent: leaf.content,
html: data.expanded ? icons.open : icons.closed,
className: `${this.prefixClassName}-expand`
})
leaf.name = utils.html({ parent: leaf.content, html: data.name, className: `${this.prefixClassName}-name` })
leaf.name.addEventListener('mousedown', e => this._input._down(e))
leaf.name.addEventListener('touchstart', e => this._input._down(e))
for (let child of data.children) {
const add = this._leaf(child, level + 1)
add.data.parent = data
leaf.appendChild(add)
if (!data.expanded) {
add.style.display = 'none'
}
}
if (this._getChildren(leaf, true).length === 0) {
this._hideIcon(leaf)
}
clicked(leaf.icon, () => this.toggleExpand(leaf))
this.emit('render', leaf, this)
return leaf
}
_getChildren(leaf, all) {
leaf = leaf || this.element
const children = []
for (let child of leaf.children) {
if (child.isLeaf && (all || child.style.display !== 'none')) {
children.push(child)
}
}
return children
}
_hideIcon(leaf) {
if (leaf.isLeaf) {
leaf.icon.style.opacity = 0
leaf.icon.style.cursor = 'unset'
}
}
_showIcon(leaf) {
if (leaf.isLeaf) {
leaf.icon.style.opacity = 1
leaf.icon.style.cursor = this._options.cursorExpand
}
}
/** Expands all leaves */
expandAll() {
this._expandChildren(this.element)
}
_expandChildren(leaf) {
for (let child of this._getChildren(leaf, true)) {
this.expand(child)
this._expandChildren(child)
}
}
/** Collapses all leaves */
collapseAll() {
this._collapseChildren(this)
}
_collapseChildren(leaf) {
for (let child of this._getChildren(leaf, true)) {
this.collapse(child)
this._collapseChildren(child)
}
}
/**
* Toggles a leaf
* @param {HTMLElement} leaf
*/
toggleExpand(leaf) {
if (leaf.icon.style.opacity !== '0') {
if (leaf.data.expanded) {
this.collapse(leaf)
} else {
this.expand(leaf)
}
}
}
/**
* Expands a leaf
* @param {HTMLElement} leaf
*/
expand(leaf) {
if (leaf.isLeaf) {
const children = this._getChildren(leaf, true)
if (children.length) {
for (let child of children) {
child.style.display = 'block'
}
leaf.data.expanded = true
leaf.icon.innerHTML = icons.open
this.emit('expand', leaf, this)
this.emit('update', leaf, this)
}
}
}
/**
* Collapses a leaf
* @param {HTMLElement} leaf
*/
collapse(leaf) {
if (leaf.isLeaf) {
const children = this._getChildren(leaf, true)
if (children.length) {
for (let child of children) {
child.style.display = 'none'
}
leaf.data.expanded = false
leaf.icon.innerHTML = icons.closed
this.emit('collapse', leaf, this)
this.emit('update', leaf, this)
}
}
}
/** call this after tree's data has been updated outside of this library */
update() {
const scroll = this.element.scrollTop
utils.removeChildren(this.element)
for (let leaf of this.element.data.children) {
const add = this._leaf(leaf, 0)
add.data.parent = this.element.data
this.element.appendChild(add)
}
this.element.scrollTop = scroll + 'px'
}
/**
* edit the name entry using the data
* @param {object} data element of leaf
*/
editData(data) {
const children = this._getChildren(null, true)
for (let child of children) {
if (child.data === data) {
this.edit(child)
}
}
}
/**
* edit the name entry using the created element
* @param {HTMLElement} leaf
*/
edit(leaf) {
this._editing = leaf
this._editInput = utils.html({ parent: this._editing.name.parentNode, type: 'input', className: `${this.prefixClassName}-name` })
const computed = window.getComputedStyle(this._editing.name)
this._editInput.style.boxSizing = 'content-box'
this._editInput.style.fontFamily = computed.getPropertyValue('font-family')
this._editInput.style.fontSize = computed.getPropertyValue('font-size')
this._editInput.value = this._editing.name.innerText
this._editInput.setSelectionRange(0, this._editInput.value.length)
this._editInput.focus()
this._editInput.addEventListener('update', () => {
this.nameChange(this._editing, this._editInput.value)
this._holdClose()
})
this._editInput.addEventListener('keyup', (e) => {
if (e.code === 'Escape') {
this._holdClose()
}
if (e.code === 'Enter') {
this.nameChange(this._editing, this._editInput.value)
this._holdClose()
}
})
this._editing.name.style.display = 'none'
this._target = null
}
_holdClose() {
if (this._editing) {
this._editInput.remove()
this._editing.name.style.display = 'block'
this._editing = this._editInput = null
}
}
/**
* change the name of a leaf
* @param {HTMLElement} leaf
* @param {string} name
*/
nameChange(leaf, name) {
leaf.data.name = this._input.value
leaf.name.innerHTML = name
this.emit('name-change', leaf, this._input.value, this)
this.emit('update', leaf, this)
}
/**
* Find a leaf based using its data
* @param {object} leaf
* @param {HTMLElement} [root=this.element]
*/
getLeaf(leaf, root = this.element) {
this.findInTree(root, data => data === leaf)
}
/**
* call the callback function on each node; returns the node if callback === true
* @param {*} leaf data
* @param {function} callback
*/
findInTree(leaf, callback) {
for (const child of leaf.children) {
if (callback(child)) {
return child
}
const find = this.findInTree(child, callback)
if (find) {
return find
}
}
}
_getFirstChild(element, all) {
const children = this._getChildren(element, all)
if (children.length) {
return children[0]
}
}
_getLastChild(element, all) {
const children = this._getChildren(element, all)
if (children.length) {
return children[children.length - 1]
}
}
_getParent(element) {
element = element.parentNode
while (element.style.display === 'none') {
element = element.parentNode
}
return element
}
_addStyles(userStyles) {
const styles = utils.options(userStyles, styleDefaults)
let s = `.${this.prefixClassName}-name{`
for (const key in styles.nameStyles) {
s += `${key}:${styles.nameStyles[key]};`
}
s += `}.${this.prefixClassName}-content{`
for (const key in styles.contentStyles) {
s += `${key}:${styles.contentStyles[key]};`
}
s += `}.${this.prefixClassName}-indicator{`
for (const key in styles.indicatorStyles) {
s += `${key}:${styles.indicatorStyles[key]};`
}
s += `}.${this.prefixClassName}-expand{`
for (const key in styles.expandStyles) {
s += `${key}:${styles.expandStyles[key]};`
}
s += `}.${this.prefixClassName}-select{`
for (const key in styles.selectStyles) {
s += `${key}:${styles.selectStyles[key]};`
}
s + '}'
const style = document.createElement('style')
style.innerHTML = s
document.head.appendChild(style)
}
}
/**
* @typedef {Object} Tree~TreeData
* @property {TreeData[]} children
* @property {string} name
* @property {parent} [parent] if not provided then will traverse tree to find parent
*/
/**
* trigger when expand is called either through UI interaction or Tree.expand()
* @event Tree~expand
* @type {object}
* @property {HTMLElement} tree element
* @property {Tree} Tree
*/
/**
* trigger when collapse is called either through UI interaction or Tree.expand()
* @event Tree~collapse
* @type {object}
* @property {HTMLElement} tree element
* @property {Tree} Tree
*/
/**
* trigger when name is change either through UI interaction or Tree.nameChange()
* @event Tree~name-change
* @type {object}
* @property {HTMLElement} tree element
* @property {string} name
* @property {Tree} Tree
*/
/**
* trigger when a leaf is picked up through UI interaction
* @event Tree~move-pending
* @type {object}
* @property {HTMLElement} tree element
* @property {Tree} Tree
*/
/**
* trigger when a leaf's location is changed
* @event Tree~move
* @type {object}
* @property {HTMLElement} tree element
* @property {Tree} Tree
*/
/**
* trigger when a leaf is clicked and not dragged or held
* @event Tree~clicked
* @type {object}
* @property {HTMLElement} tree element
* @property {UIEvent} event
* @property {Tree} Tree
*/
/**
* trigger when a leaf is changed (i.e., moved, name-change)
* @event Tree~update
* @type {object}
* @property {HTMLElement} tree element
* @property {Tree} Tree
*/
/**
* trigger when a leaf's div is created
* @event Tree~render
* @type {object}
* @property {HTMLElement} tree element
* @property {Tree} Tree
*/