File

src/lib/tree.component.ts

Implements

OnInit AfterContentInit

Metadata

Index

Properties
Methods
Inputs
Outputs
HostListeners
Accessors

Constructor

constructor(viewContainerRef: ViewContainerRef, cdr: ChangeDetectorRef, contentEditableMethod: Method | null, renderer: Renderer2, elementRef: ElementRef, searchForm: SearchForm | null)
Parameters :
Name Type Optional
viewContainerRef ViewContainerRef No
cdr ChangeDetectorRef No
contentEditableMethod Method<any | string | null> | null No
renderer Renderer2 No
elementRef ElementRef<HTMLElement> No
searchForm SearchForm | null No

Inputs

contentEditableMethod
Type : Method<any | string | null> | null
dataSource
Type : TreeDataSource<Data>
Required :  true
dividerOffset
Type : string
Default value : '256px'
getIcon
Type : NodeGetIconFunction<any>
getStyle
Type : NodeGetStyleFunction<any>
getType
Type : NodeGetTypeFunction<any>
hasDetails
Type : NodeHasDetailsFunction<any>
hideLeafIcon
Type : boolean
Default value : false
id
Type : string
multiple
Type : boolean
Default value : false
toDisplay
Type : NodeToDisplayFunction<any>

Outputs

details
Type : EventEmitter

HostListeners

mousemove
Arguments : '$event'
mousemove($event: MouseEvent)
mouseup
mouseup()

Methods

Public onContentEditableChange
onContentEditableChange(value: string | null, node: Node)
Parameters :
Name Type Optional
value string | null No
node Node<Data> No
Returns : any
onMousedown
onMousedown()
Returns : void
onMousemove
onMousemove($event: MouseEvent)
Decorators :
@HostListener('mousemove', ['$event'])
Parameters :
Name Type Optional
$event MouseEvent No
Returns : void
onMouseup
onMouseup()
Decorators :
@HostListener('mouseup')
Returns : void
Public openDetails
openDetails(node: Node)
Decorators :
@DebounceCall(100)
Parameters :
Name Type Optional
node Node<Data> No
Returns : void
toggleTreeNavigation
toggleTreeNavigation()
Returns : void

Properties

Public Optional content
Type : TreeContentDirective
Decorators :
@ContentChild(TreeContentDirective, {static: true})
Public getLevel
Default value : () => {...}
Public hasChild
Default value : () => {...}
Public isExpandable
Default value : () => {...}
Public portal
Type : TemplatePortal | null
Default value : null
Public Readonly searchForm
Type : SearchForm | null
Decorators :
@Optional()
@Inject(RXAP_FORM_DEFINITION)
Public Readonly showTreeNavigation
Default value : signal(true)
Public treeContainer
Type : ElementRef
Decorators :
@ViewChild('treeContainer', {static: true})
Public treeControl
Type : FlatTreeControl<Node<Data>>

Accessors

nodeDisplayEditable
getnodeDisplayEditable()
cacheId
getcacheId()
import {
  PortalModule,
  TemplatePortal,
} from '@angular/cdk/portal';
import { FlatTreeControl } from '@angular/cdk/tree';
import {
  AsyncPipe,
  NgClass,
  NgForOf,
  NgIf,
  NgStyle,
} from '@angular/common';
import {
  AfterContentInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChild,
  ElementRef,
  EventEmitter,
  HostListener,
  Inject,
  Input,
  isDevMode,
  OnInit,
  Optional,
  Output,
  Renderer2,
  signal,
  ViewChild,
  ViewContainerRef,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTreeModule } from '@angular/material/tree';
import { ContenteditableDirective } from '@rxap/contenteditable';
import {
  Node,
  NodeGetIconFunction,
  NodeGetStyleFunction,
  NodeGetTypeFunction,
  NodeHasDetailsFunction,
  NodeToDisplayFunction,
} from '@rxap/data-structure-tree';
import { RXAP_FORM_DEFINITION } from '@rxap/forms';
import { IconDirective } from '@rxap/material-directives/icon';
import { Method } from '@rxap/pattern';
import {
  DebounceCall,
  WithChildren,
  WithIdentifier,
} from '@rxap/utilities';
import {
  map,
  startWith,
  tap,
} from 'rxjs/operators';
import { SearchForm } from './search.form';
import { RXAP_TREE_CONTENT_EDITABLE_METHOD } from './tokens';
import { TreeContentDirective } from './tree-content.directive';
import { TreeDataSource } from './tree.data-source';

@Component({
    selector: 'rxap-tree',
    templateUrl: './tree.component.html',
    styleUrls: ['./tree.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush,
    imports: [
        NgStyle,
        NgIf,
        MatProgressBarModule,
        MatTreeModule,
        MatIconModule,
        IconDirective,
        MatCheckboxModule,
        MatButtonModule,
        ContenteditableDirective,
        MatProgressSpinnerModule,
        MatDividerModule,
        PortalModule,
        AsyncPipe,
        NgClass,
        NgForOf,
    ]
})
export class TreeComponent<Data extends WithIdentifier & WithChildren = any>
  implements OnInit, AfterContentInit {
  public treeControl: FlatTreeControl<Node<Data>>;
  @Input({required: true})
  public dataSource!: TreeDataSource<Data>;
  @Input()
  public contentEditableMethod?: Method<any, string | null> | null;
  @Input()
  public toDisplay?: NodeToDisplayFunction<any>;
  @Input()
  public getIcon?: NodeGetIconFunction<any>;
  @Input()
  public getType?: NodeGetTypeFunction<any>;
  @Input()
  public getStyle?: NodeGetStyleFunction<any>;
  @Input()
  public multiple                      = false;
  @Input()
  public hasDetails?: NodeHasDetailsFunction<any>;
  @ContentChild(TreeContentDirective, {static: true})
  public content?: TreeContentDirective;
  @Input()
  public hideLeafIcon                  = false;
  @Input()
  public id?: string;
  @Output()
  public details                       = new EventEmitter();
  @Input()
  public dividerOffset                 = '256px';
  public portal: TemplatePortal | null = null;
  @ViewChild('treeContainer', {static: true})
  public treeContainer!: ElementRef;

  public readonly showTreeNavigation = signal(true);

  /**
   * Indicates that the divider is moved with mouse down
   * @private
   */
  private _moveDivider                       = false;
  /**
   * Holds the current tree container width.
   * If null the move divider feature was not yet used and the initial
   * container width is not calculated
   * @private
   */
  private _treeContainerWidth: number | null = null;

  constructor(
    @Inject(ViewContainerRef)
    private readonly viewContainerRef: ViewContainerRef,
    @Inject(ChangeDetectorRef)
    private readonly cdr: ChangeDetectorRef,
    @Optional()
    @Inject(RXAP_TREE_CONTENT_EDITABLE_METHOD)
      contentEditableMethod: Method<any, string | null> | null,
    private readonly renderer: Renderer2,
    private readonly elementRef: ElementRef<HTMLElement>,
    @Optional()
    @Inject(RXAP_FORM_DEFINITION)
    public readonly searchForm: SearchForm | null,
  ) {
    this.treeControl           = new FlatTreeControl(this.getLevel, this.isExpandable);
    this.contentEditableMethod = contentEditableMethod;
  }

  public get nodeDisplayEditable(): boolean {
    return !!this.contentEditableMethod;
  }

  public get cacheId() {
    return [ 'rxap', 'tree', this.id ].join('/');
  }

  public getLevel = (node: Node<Data>) => node.depth;

  public isExpandable = (node: Node<Data>) => node.hasChildren;

  public hasChild = (_: number, nodeData: Node<Data>) => nodeData.hasChildren;

  public ngOnInit() {
    this.dataSource.searchForm = this.searchForm;
    this.dataSource.setTreeControl(this.treeControl);
    this.dataSource.setToDisplay(this.toDisplay);
    this.dataSource.setGetIcon(this.getIcon);
    this.dataSource.setHasDetails(this.hasDetails);
    this.dataSource.setGetStyle(this.getStyle);
    this.dataSource.setGetType(this.getType);
    this.multiple = this.dataSource.metadata.selectMultiple ?? this.multiple;

    if (this.dataSource.selected.hasValue()) {
      this.dataSource.selected.selected.forEach((node) =>
        this.openDetails(node),
      );
    }

    const cachedOffset = localStorage.getItem(this.cacheId);
    if (cachedOffset && cachedOffset.match(/^(\d+\.)?\d+px$/)) {
      this.setDividerOffset(cachedOffset);
    } else if (isDevMode()) {
      console.log('Divider offset cache is not available or invalid: ' + cachedOffset);
    }
  }

  public ngAfterContentInit(): void {
    this.dataSource.selected.changed
    .pipe(
      map(($event) => $event.source.selected),
      startWith(this.dataSource.selected.selected),
      tap((selected) => selected.forEach((node) => this.openDetails(node))),
    )
    .subscribe();
  }

  @DebounceCall(100)
  public openDetails(node: Node<Data>): void {
    if (this.content) {
      this.portal = new TemplatePortal<any>(
        this.content.template,
        this.viewContainerRef,
        {
          $implicit: node.item,
          node,
        },
      );
      this.cdr.markForCheck();
    }
    this.details.emit(node.item);
  }

  public onContentEditableChange(value: string | null, node: Node<Data>) {
    return this.contentEditableMethod?.call(value, node.item, node);
  }


  onMousedown() {
    this._moveDivider = true;
  }

  @HostListener('mouseup')
  onMouseup() {
    this._moveDivider = false;
  }

  @HostListener('mousemove', [ '$event' ])
  onMousemove($event: MouseEvent) {
    if (this._moveDivider) {
      if (!this._treeContainerWidth) {
        this._treeContainerWidth = this.treeContainer.nativeElement.clientWidth as number;
      }
      const rect               = this.elementRef.nativeElement.getBoundingClientRect();
      this._treeContainerWidth = Math.min(Math.max($event.clientX - (
        rect.left + 12
      ), 128), rect.right - rect.left - 128);
      const offset             = this._treeContainerWidth + 'px';
      this.setDividerOffset(offset);
    }
  }

  toggleTreeNavigation() {
    this.showTreeNavigation.update((value) => !value);
  }

  private setDividerOffset(offset: string) {
    this.dividerOffset = offset;
    this.renderer.setStyle(this.treeContainer.nativeElement, 'max-width', offset);
    this.renderer.setStyle(this.treeContainer.nativeElement, 'min-width', offset);
    this.renderer.setStyle(this.treeContainer.nativeElement, 'flex-basis', offset);
    localStorage.setItem(this.cacheId, offset);
  }
}
<div class="grow flex flex-col gap-2 justify-start items-start">
  <button (click)="toggleTreeNavigation()" class="!justify-start" mat-button type="button">
    <span class="flex flex-row justify-start items-center gap-6">
      <ng-template [ngIf]="showTreeNavigation()">
        <mat-icon>arrow_back</mat-icon>
        <span i18n>Hide tree navigation</span>
      </ng-template>
      <ng-template [ngIf]="!showTreeNavigation()">
        <mat-icon>arrow_forward</mat-icon>
        <span i18n>Show tree navigation</span>
      </ng-template>
    </span>
  </button>
  <div class="flex flex-row grow w-full">
    <div #treeContainer [ngClass]="{ 'hidden': !showTreeNavigation() }"
         [ngStyle]="{ maxWidth: dividerOffset, minWidth: dividerOffset, flexBasis: dividerOffset }"
         class="w-fit grow-0 overflow-y-auto">
      <mat-progress-bar *ngIf="!dataSource || (dataSource.loading$ | async)" mode="indeterminate"></mat-progress-bar>
      <ng-content select="[searchHeader]"></ng-content>
      <mat-tree *ngIf="dataSource; else loading" [dataSource]="dataSource" [treeControl]="treeControl">

        <!-- Node without children -->
        <mat-tree-node *matTreeNodeDef="let node" matTreeNodePadding>
          <div class="flex flex-row justify-start items-center gap-2">
            <mat-icon [ngClass]="{ 'hidden': hideLeafIcon }">subdirectory_arrow_right</mat-icon>
            <ng-container *ngIf="node.icon?.length">
              <mat-icon *ngFor="let icon of node.icon" [rxapIcon]="$any(icon)"></mat-icon>
            </ng-container>
            <ng-template [ngIf]="multiple">
              <mat-checkbox
                (change)="node.toggleSelect()"
                [checked]="node.selected"
                [disabled]="!node.hasDetails"
                [ngStyle]="node.style"
                class="grow-0 truncate">
                {{ node.display }}
              </mat-checkbox>
            </ng-template>
            <ng-template [ngIf]="!multiple">
              <button
                (click)="node.select()"
                [color]="node.selected ? 'primary' : undefined"
                [disabled]="!node.hasDetails"
                [ngStyle]="node.style"
                class="grow-0 truncate"
                mat-button
                type="button"
              >
                {{ node.display }}
              </button>
            </ng-template>
          </div>
        </mat-tree-node>

        <!-- Node with children -->
        <mat-tree-node *matTreeNodeDef="let node; when: hasChild" matTreeNodePadding>
          <button [attr.aria-label]="'toggle ' + node.filename" mat-icon-button matTreeNodeToggle type="button">
            <mat-icon class="mat-icon-rtl-mirror">
              {{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}
            </mat-icon>
          </button>
          <div class="flex flex-row justify-start items-center gap-2">
            <ng-container *ngIf="node.icon?.length else withoutIcon">
              <mat-icon *ngFor="let icon of node.icon" [rxapIcon]="$any(icon)"></mat-icon>
            </ng-container>
            <ng-template #withoutIcon>
              <div></div>
            </ng-template>
            <ng-template [ngIfElse]="withoutDetails" [ngIf]="node.hasDetails">
              <button
                (click)="node.select()"
                [color]="node.selected ? 'primary' : undefined"
                [ngStyle]="node.style"
                class="grow-0 truncate"
                mat-button
                type="button"
              >
                {{ node.display }}
              </button>
            </ng-template>
            <ng-template #withoutDetails>
              <span
                (change)="onContentEditableChange($event, node)"
                [disabled]="!nodeDisplayEditable"
                [ngStyle]="node.style"
                class="grow-0 truncate"
                rxapContenteditable>
                {{ node.display }}
              </span>
            </ng-template>
            <mat-progress-spinner
              *ngIf="node.isLoading$ | async"
              [diameter]="16"
              class="grow-0 pl-4"
              mode="indeterminate"
            ></mat-progress-spinner>
          </div>
        </mat-tree-node>

      </mat-tree>
    </div>

    <div (mousedown)="onMousedown()" *ngIf="showTreeNavigation()"
         class="divider cursor-ew-resize px-3 grow-0">
      <mat-divider [vertical]="true" class="h-full"></mat-divider>
    </div>

    <div class="grow">
      <ng-container *ngIf="portal">
        <ng-template [cdkPortalOutlet]="portal"></ng-template>
      </ng-container>
      <ng-content></ng-content>
    </div>
  </div>
</div>

<ng-template #loading>Load data source</ng-template>

./tree.component.scss

Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""