preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
371 lines (296 loc) • 9.63 kB
text/typescript
/*
* HSTreeView
* @version: 3.0.1
* @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 {
ITreeViewOptions,
ITreeView,
ITreeViewItem,
ITreeViewOptionsControlBy,
} from './interfaces';
import HSBasePlugin from '../base-plugin';
import { ICollectionItem } from '../../interfaces';
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-hs-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.hs.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-hs-tree-view-item]').forEach((el, ind) => {
const data = JSON.parse(el.getAttribute('data-hs-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-hs-tree-view-item]');
while (!!parent) {
const data = JSON.parse(parent.getAttribute('data-hs-tree-view-item'));
path.push(data.value);
parent = parent.parentElement?.closest('[data-hs-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-hs-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-hs-tree-view-item]');
while (!!parent) {
const items = parent.querySelectorAll(
'[data-hs-tree-view-item]:not(.disabled)',
);
const data = JSON.parse(parent.getAttribute('data-hs-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-hs-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-hs-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;