flyonui
Version:
The easiest, free and open-source Tailwind CSS component library with semantic classes.
337 lines (262 loc) • 9.87 kB
text/typescript
/*
* HSTreeView
* @version: 3.2.2
* @author: Preline Labs Ltd.
* @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html)
* Copyright 2024 Preline Labs Ltd.
*/
import { dispatch } from '../../utils'
import { ITreeView, ITreeViewItem, ITreeViewOptions, ITreeViewOptionsControlBy } from './interfaces'
import { ICollectionItem } from '../../interfaces'
import HSBasePlugin from '../base-plugin'
class HSTreeView extends HSBasePlugin<ITreeViewOptions> implements ITreeView {
private items: ITreeViewItem[] = []
private readonly controlBy: ITreeViewOptionsControlBy
private readonly autoSelectChildren: boolean
private readonly isIndeterminate: boolean
static group: number = 0
private onElementClickListener:
| {
el: Element
fn: (evt: PointerEvent) => void
}[]
| null
private onControlChangeListener:
| {
el: Element
fn: () => void
}[]
| null
constructor(el: HTMLElement, options?: ITreeViewOptions, events?: {}) {
super(el, options, events)
const data = el.getAttribute('data-tree-view')
const dataOptions: ITreeView = data ? JSON.parse(data) : {}
const concatOptions = {
...dataOptions,
...options
}
this.controlBy = concatOptions?.controlBy || 'button'
this.autoSelectChildren = concatOptions?.autoSelectChildren || false
this.isIndeterminate = concatOptions?.isIndeterminate || true
this.onElementClickListener = []
this.onControlChangeListener = []
this.init()
}
private elementClick(evt: PointerEvent, el: Element, data: ITreeViewItem) {
evt.stopPropagation()
if (el.classList.contains('disabled')) return false
if (!evt.metaKey && !evt.shiftKey) this.unselectItem(data)
this.selectItem(el, data)
this.fireEvent('click', {
el,
data: data
})
dispatch('click.treeView', this.el, {
el,
data: data
})
}
private controlChange(el: Element, data: ITreeViewItem) {
if (this.autoSelectChildren) {
this.selectItem(el, data)
if (data.isDir) this.selectChildren(el, data)
this.toggleParent(el)
} else this.selectItem(el, data)
}
private init() {
this.createCollection(window.$hsTreeViewCollection, this)
HSTreeView.group += 1
this.initItems()
}
private initItems() {
this.el.querySelectorAll('[data-tree-view-item]').forEach((el, ind) => {
const data = JSON.parse(el.getAttribute('data-tree-view-item'))
if (!el.id) el.id = `tree-view-item-${HSTreeView.group}-${ind}`
const concatData = {
...data,
id: data.id ?? el.id,
path: this.getPath(el),
isSelected: data.isSelected ?? false
}
this.items.push(concatData)
if (this.controlBy === 'checkbox') this.controlByCheckbox(el, concatData)
else this.controlByButton(el, concatData)
})
}
private controlByButton(el: Element, data: ITreeViewItem) {
this.onElementClickListener.push({
el,
fn: (evt: PointerEvent) => this.elementClick(evt, el, data)
})
el.addEventListener('click', this.onElementClickListener.find(elI => elI.el === el).fn)
}
private controlByCheckbox(el: Element, data: ITreeViewItem) {
const control = el.querySelector(`input[value="${data.value}"]`)
if (!!control) {
this.onControlChangeListener.push({
el: control,
fn: () => this.controlChange(el, data)
})
control.addEventListener('change', this.onControlChangeListener.find(elI => elI.el === control).fn)
}
}
private getItem(id: string) {
return this.items.find(el => el.id === id)
}
private getPath(el: Element) {
const path: any = []
let parent = el.closest('[data-tree-view-item]')
while (!!parent) {
const data = JSON.parse(parent.getAttribute('data-tree-view-item'))
path.push(data.value)
parent = parent.parentElement?.closest('[data-tree-view-item]')
}
return path.reverse().join('/')
}
private unselectItem(exception: ITreeViewItem | null = null) {
let selectedItems = this.getSelectedItems()
if (exception) selectedItems = selectedItems.filter(el => el.id !== exception.id)
if (selectedItems.length) {
selectedItems.forEach(el => {
const selectedElement = document.querySelector(`#${el.id}`)
selectedElement.classList.remove('selected')
this.changeItemProp(el.id, 'isSelected', false)
})
}
}
private selectItem(el: Element, data: ITreeViewItem) {
if (data.isSelected) {
el.classList.remove('selected')
this.changeItemProp(data.id, 'isSelected', false)
} else {
el.classList.add('selected')
this.changeItemProp(data.id, 'isSelected', true)
}
}
private selectChildren(el: Element, data: ITreeViewItem) {
const items = el.querySelectorAll('[data-tree-view-item]')
Array.from(items)
.filter(elI => !elI.classList.contains('disabled'))
.forEach(elI => {
const initialItemData = elI.id ? this.getItem(elI.id) : null
if (!initialItemData) return false
if (data.isSelected) {
elI.classList.add('selected')
this.changeItemProp(initialItemData.id, 'isSelected', true)
} else {
elI.classList.remove('selected')
this.changeItemProp(initialItemData.id, 'isSelected', false)
}
const currentItemData = this.getItem(elI.id)
const control: HTMLFormElement = elI.querySelector(`input[value="${currentItemData.value}"]`)
if (this.isIndeterminate) control.indeterminate = false
if (currentItemData.isSelected) control.checked = true
else control.checked = false
})
}
private toggleParent(el: Element) {
let parent = el.parentElement?.closest('[data-tree-view-item]')
while (!!parent) {
const items = parent.querySelectorAll('[data-tree-view-item]:not(.disabled)')
const data = JSON.parse(parent.getAttribute('data-tree-view-item'))
const control: HTMLFormElement = parent.querySelector(`input[value="${data.value}"]`)
let hasUnchecked = false
let checkedItems = 0
items.forEach(elI => {
const dataI = this.getItem(elI.id)
if (dataI.isSelected) checkedItems += 1
if (!dataI.isSelected) hasUnchecked = true
})
if (hasUnchecked) {
parent.classList.remove('selected')
this.changeItemProp(parent.id, 'isSelected', false)
control.checked = false
} else {
parent.classList.add('selected')
this.changeItemProp(parent.id, 'isSelected', true)
control.checked = true
}
if (this.isIndeterminate) {
if (checkedItems > 0 && checkedItems < items.length) control.indeterminate = true
else control.indeterminate = false
}
parent = parent.parentElement?.closest('[data-tree-view-item]')
}
}
// Public methods
public update() {
this.items.map((el: ITreeViewItem) => {
const selector = document.querySelector(`#${el.id}`)
if (el.path !== this.getPath(selector)) el.path = this.getPath(selector)
return el
})
}
public getSelectedItems() {
return this.items.filter(el => el.isSelected)
}
public changeItemProp(id: string, prop: string, val: any) {
this.items.map(el => {
// @ts-ignore
if (el.id === id) el[prop] = val
return el
})
}
public destroy() {
// Remove listeners
this.onElementClickListener.forEach(({ el, fn }) => {
el.removeEventListener('click', fn)
})
if (this.onControlChangeListener.length)
this.onElementClickListener.forEach(({ el, fn }) => {
el.removeEventListener('change', fn)
})
this.unselectItem()
this.items = []
window.$hsTreeViewCollection = window.$hsTreeViewCollection.filter(({ element }) => element.el !== this.el)
HSTreeView.group -= 1
}
// Static methods
private static findInCollection(target: HSTreeView | HTMLElement | string): ICollectionItem<HSTreeView> | null {
return (
window.$hsTreeViewCollection.find(el => {
if (target instanceof HSTreeView) return el.element.el === target.el
else if (typeof target === 'string') return el.element.el === document.querySelector(target)
else return el.element.el === target
}) || null
)
}
static getInstance(target: HTMLElement | string, isInstance?: boolean) {
const elInCollection = window.$hsTreeViewCollection.find(
el => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target)
)
return elInCollection ? (isInstance ? elInCollection : elInCollection.element.el) : null
}
static autoInit() {
if (!window.$hsTreeViewCollection) window.$hsTreeViewCollection = []
if (window.$hsTreeViewCollection)
window.$hsTreeViewCollection = window.$hsTreeViewCollection.filter(({ element }) => document.contains(element.el))
document.querySelectorAll('[data-tree-view]:not(.--prevent-on-load-init)').forEach((el: HTMLElement) => {
if (!window.$hsTreeViewCollection.find(elC => (elC?.element?.el as HTMLElement) === el)) new HSTreeView(el)
})
}
// Backward compatibility
static on(evt: string, target: HSTreeView | HTMLElement | string, cb: Function) {
const instance = HSTreeView.findInCollection(target)
if (instance) instance.element.events[evt] = cb
}
}
declare global {
interface Window {
HSTreeView: Function
$hsTreeViewCollection: ICollectionItem<HSTreeView>[]
}
}
window.addEventListener('load', () => {
HSTreeView.autoInit()
// Uncomment for debug
// console.log('Tree view collection:', window.$hsTreeViewCollection);
})
if (typeof window !== 'undefined') {
window.HSTreeView = HSTreeView
}
export default HSTreeView