#91 - replace sidebar tree component with custom 1st-party implementation
parent
87b99473bd
commit
f3f8578834
@ -0,0 +1,37 @@
|
|||||||
|
<div [ngClass]="{container: true, dark: isDark()}">
|
||||||
|
<ng-container *ngFor="let item of items">
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
*ngIf="!item.__app_tree_comp_is_hidden"
|
||||||
|
(click)="onItemClick($event, item)"
|
||||||
|
(dblclick)="onItemDoubleClick($event, item)"
|
||||||
|
(contextmenu)="onItemRightClick($event, item)"
|
||||||
|
[ngClass]="{activated: selectedItem === item}"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
*ngIf="hasNesting(item) && !item.__app_tree_comp_is_collapsed"
|
||||||
|
(click)="onItemHandleClick($event, item)"
|
||||||
|
class="fa fa-chevron-down handle handle-expanded"
|
||||||
|
></i>
|
||||||
|
<i
|
||||||
|
*ngIf="hasNesting(item) && item.__app_tree_comp_is_collapsed"
|
||||||
|
(click)="onItemHandleClick($event, item)"
|
||||||
|
class="fa fa-chevron-right handle handle-collapsed"
|
||||||
|
></i>
|
||||||
|
<i *ngIf="iconClassField" [ngClass]="item[iconClassField]"></i>
|
||||||
|
{{ item[displayField] }}
|
||||||
|
</div>
|
||||||
|
<div class="nested-level" *ngIf="hasNesting(item, true) && !item.__app_tree_comp_is_collapsed">
|
||||||
|
<app-tree-root
|
||||||
|
#childComponents
|
||||||
|
[displayField]="displayField"
|
||||||
|
[childrenField]="childrenField"
|
||||||
|
[nestingLevel]="nestingLevel + 1"
|
||||||
|
[iconClassField]="iconClassField"
|
||||||
|
[items]="item[childrenField]"
|
||||||
|
[parent]="this"
|
||||||
|
[parentItem]="item"
|
||||||
|
></app-tree-root>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
@ -0,0 +1,38 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nested-level {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #cccccc;
|
||||||
|
border-left: 1px solid #cccccc;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
i {
|
||||||
|
padding-right: 10px;
|
||||||
|
|
||||||
|
&.handle {
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &.activated {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.dark {
|
||||||
|
.item {
|
||||||
|
border-color: #555555;
|
||||||
|
|
||||||
|
&:hover, &.activated {
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,253 @@
|
|||||||
|
import {Component, EventEmitter, Input, OnInit, Output, ViewChildren} from '@angular/core';
|
||||||
|
import {debug} from '../../utility';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recursive tree listing component.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tree-root',
|
||||||
|
templateUrl: './tree-root.component.html',
|
||||||
|
styleUrls: ['./tree-root.component.scss'],
|
||||||
|
})
|
||||||
|
export class TreeRootComponent implements OnInit {
|
||||||
|
/** The immediate children of this component. */
|
||||||
|
@ViewChildren('childComponents')
|
||||||
|
protected childComponents;
|
||||||
|
|
||||||
|
/** How deeply nested this component is in the tree. */
|
||||||
|
@Input()
|
||||||
|
public nestingLevel = 0;
|
||||||
|
|
||||||
|
/** Field on the listing record with the display string. */
|
||||||
|
@Input()
|
||||||
|
public displayField = 'name';
|
||||||
|
|
||||||
|
/** Field on the listing record that contains the children of an item. */
|
||||||
|
@Input()
|
||||||
|
public childrenField = 'children';
|
||||||
|
|
||||||
|
/** Optionally, field on the listing record that contains the <i> icon class. */
|
||||||
|
@Input()
|
||||||
|
public iconClassField?: string;
|
||||||
|
|
||||||
|
/** The nested listing items. */
|
||||||
|
@Input()
|
||||||
|
public items: any[] = [];
|
||||||
|
|
||||||
|
/** @private Set by the parent component when recursively nested. */
|
||||||
|
@Input()
|
||||||
|
public parent?: TreeRootComponent;
|
||||||
|
|
||||||
|
/** @private Item object that created this listing when recursively nested. */
|
||||||
|
@Input()
|
||||||
|
public parentItem?: any;
|
||||||
|
|
||||||
|
/** Emits the selected item, when top-level tree. */
|
||||||
|
@Output()
|
||||||
|
public itemSelected: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>();
|
||||||
|
|
||||||
|
/** Emits the activated item, when top-level tree. */
|
||||||
|
@Output()
|
||||||
|
public itemActivated: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>();
|
||||||
|
|
||||||
|
/** Emits the right-clicked item, when top-level tree. */
|
||||||
|
@Output()
|
||||||
|
public itemRightClicked: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>();
|
||||||
|
|
||||||
|
/** The selected item record at this level, if one exists. */
|
||||||
|
public selectedItem?: any;
|
||||||
|
|
||||||
|
/** The current tree filter value, if one exists. */
|
||||||
|
protected filterValue?: string;
|
||||||
|
|
||||||
|
/** Called on initialization. */
|
||||||
|
ngOnInit() {
|
||||||
|
if ( !this.parent ) {
|
||||||
|
debug('Top-level tree root component:', this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if we're in dark mode. */
|
||||||
|
public isDark() {
|
||||||
|
return document.body.classList.contains('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively filter the tree using the given quick-filter string. */
|
||||||
|
public filterTree(filterValue?: string) {
|
||||||
|
this.setFilterRecursively(filterValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an item selected event, or bubble up to the parent. */
|
||||||
|
public emitItemSelected(event: MouseEvent, item: any) {
|
||||||
|
if ( this.parent ) {
|
||||||
|
return this.parent.emitItemSelected(event, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemSelected.emit({
|
||||||
|
event,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an item activated event, or bubble up to the parent. */
|
||||||
|
public emitItemActivated(event: MouseEvent, item: any) {
|
||||||
|
if ( this.parent ) {
|
||||||
|
return this.parent.emitItemActivated(event, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemActivated.emit({
|
||||||
|
event,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an item right-clicked event, or bubble up to the parent. */
|
||||||
|
public emitItemRightClicked(event: MouseEvent, item: any) {
|
||||||
|
if ( this.parent ) {
|
||||||
|
return this.parent.emitItemRightClicked(event, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemRightClicked.emit({
|
||||||
|
event,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapper for checking arrays for use in NG templates. */
|
||||||
|
public isArray(something: unknown): boolean {
|
||||||
|
return Array.isArray(something);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if an item has nested children.
|
||||||
|
* @param item
|
||||||
|
* @param includeHidden - if true, will include children hidden by the quick-filter
|
||||||
|
*/
|
||||||
|
public hasNesting(item: any, includeHidden = false): boolean {
|
||||||
|
const hasUnfilteredNesting = item && this.isArray(item[this.childrenField]) && item[this.childrenField].length;
|
||||||
|
if ( !this.filterValue || includeHidden ) {
|
||||||
|
return hasUnfilteredNesting;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasUnfilteredNesting && item[this.childrenField].some(child => !child.__app_tree_comp_is_hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when an item expand/collapse arrow is clicked. */
|
||||||
|
public onItemHandleClick(event: MouseEvent, item: any) {
|
||||||
|
if ( this.hasNesting(item) ) {
|
||||||
|
this.setExpansionRecursively(item, !item.__app_tree_comp_is_collapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when an item is clicked. */
|
||||||
|
public onItemClick(event: MouseEvent, item: any) {
|
||||||
|
this.clearSelection();
|
||||||
|
this.selectedItem = item;
|
||||||
|
this.emitItemSelected(event, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when an item is double-clicked. */
|
||||||
|
public onItemDoubleClick(event: MouseEvent, item: any) {
|
||||||
|
this.emitItemActivated(event, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when an item is right-clicked. */
|
||||||
|
public onItemRightClick(event: MouseEvent, item: any) {
|
||||||
|
this.clearSelection();
|
||||||
|
this.selectedItem = item;
|
||||||
|
this.emitItemRightClicked(event, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively clear the selection from the entire tree, calling children and bubbling up to parent. */
|
||||||
|
public clearSelection(from?: TreeRootComponent) {
|
||||||
|
this.selectedItem = undefined;
|
||||||
|
|
||||||
|
if ( this.childComponents ) {
|
||||||
|
for ( const child of this.childComponents ) {
|
||||||
|
if ( child === from ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.clearSelection(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.parent ) {
|
||||||
|
if ( this.parent !== from ) {
|
||||||
|
this.parent.clearSelection(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively set the filter on this subtree.
|
||||||
|
* Returns true if any of the children of this level match the filter.
|
||||||
|
* @param filterValue
|
||||||
|
* @param from
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected setFilterRecursively(filterValue?: string, from?: TreeRootComponent): boolean {
|
||||||
|
this.filterValue = filterValue;
|
||||||
|
|
||||||
|
let anyChildMatchesFilter = false;
|
||||||
|
for ( const item of this.items ) {
|
||||||
|
item.__app_tree_comp_is_hidden = this.getFilterHiddenStatusForItem(item);
|
||||||
|
anyChildMatchesFilter = anyChildMatchesFilter || !item.__app_tree_comp_is_hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
return anyChildMatchesFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given item should be hidden with the current quick-filter.
|
||||||
|
* @param item
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getFilterHiddenStatusForItem(item: any): boolean {
|
||||||
|
const itemMatchesFilter = (
|
||||||
|
!this.filterValue
|
||||||
|
|| (
|
||||||
|
item[this.displayField]?.trim()?.toLowerCase()?.includes(this.filterValue?.trim()?.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( !this.hasNesting(item, true) ) {
|
||||||
|
return !itemMatchesFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
let anyChildMatchesFilter = false;
|
||||||
|
const childTree = this.getChildComponentForItem(item);
|
||||||
|
if ( childTree ) {
|
||||||
|
anyChildMatchesFilter = childTree.setFilterRecursively(this.filterValue, this);
|
||||||
|
} else {
|
||||||
|
debug('Unable to find childTree for item:', item[this.displayField]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(!this.filterValue || itemMatchesFilter || anyChildMatchesFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the TreeRootComponent instance for some item's children, if it exists.
|
||||||
|
* @param item
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getChildComponentForItem(item: any): TreeRootComponent | undefined {
|
||||||
|
return [...(this.childComponents || [])]?.find(child => {
|
||||||
|
return child.parentItem === item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand or collapse an item recursively.
|
||||||
|
* @param item
|
||||||
|
* @param isCollapsed
|
||||||
|
*/
|
||||||
|
public setExpansionRecursively(item: any, isCollapsed: boolean) {
|
||||||
|
item.__app_tree_comp_is_collapsed = isCollapsed;
|
||||||
|
if ( this.hasNesting(item, true) && isCollapsed ) {
|
||||||
|
for ( const child of item.children ) {
|
||||||
|
this.setExpansionRecursively(child, isCollapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue