From 2a13fbdf14bcac99a6baf9b1f4e02b8eb309b5ef Mon Sep 17 00:00:00 2001 From: Ivan Donchev Date: Tue, 8 Oct 2024 12:41:21 +0300 Subject: [PATCH] feat: wip - virtual tree full --- .storybook/helpers/files.data.ts | 299 +++++++++++++++++- .storybook/stories/tree/tree.stories.ts | 19 ++ .../data/tree-view/_tree-view.clarity.scss | 16 +- .../data/tree-view/models/tree-node.model.ts | 8 + .../data/tree-view/tree-features.service.ts | 14 + .../angular/src/data/tree-view/tree-node.html | 134 ++++---- .../angular/src/data/tree-view/tree-node.ts | 103 +++++- .../src/data/tree-view/tree-view.module.ts | 4 +- projects/angular/src/data/tree-view/tree.ts | 38 ++- 9 files changed, 561 insertions(+), 74 deletions(-) diff --git a/.storybook/helpers/files.data.ts b/.storybook/helpers/files.data.ts index 6f729b18e2..56c1feaffc 100644 --- a/.storybook/helpers/files.data.ts +++ b/.storybook/helpers/files.data.ts @@ -68,6 +68,300 @@ export const filesRoot: File[] = [ { name: 'main.ts', }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, + { + name: 'styles', + files: [ + { + name: 'main.scss', + }, + ], + }, + { + name: 'index.html', + }, + { + name: 'main.ts', + }, ], }, { @@ -94,7 +388,10 @@ export function getFileTreeNodeMarkup( ${args.hasIcon ? `` : ''} ${args.asLink ? `${file.name}` : file.name} ${file.files ? getFileTreeNodeMarkup(file.files, args) : ''} - ` + + + + ` ) .join(''); } diff --git a/.storybook/stories/tree/tree.stories.ts b/.storybook/stories/tree/tree.stories.ts index 1ef512cd0e..9898ca866a 100644 --- a/.storybook/stories/tree/tree.stories.ts +++ b/.storybook/stories/tree/tree.stories.ts @@ -37,10 +37,29 @@ const TreeViewTemplate: StoryFn = args => ({ props: args, }); +const VirtualTreeTemplate: StoryFn = args => ({ + template: ` + + + One! + + Two! + Three! + + + + `, + props: args, +}); + export const TreeView: StoryObj = { render: TreeViewTemplate, }; +export const VirtualTree: StoryObj = { + render: VirtualTreeTemplate, +}; + export const TreeViewAsLink: StoryObj = { render: TreeViewTemplate, args: { diff --git a/projects/angular/src/data/tree-view/_tree-view.clarity.scss b/projects/angular/src/data/tree-view/_tree-view.clarity.scss index d5d7ab1a07..fcaaedb3b7 100644 --- a/projects/angular/src/data/tree-view/_tree-view.clarity.scss +++ b/projects/angular/src/data/tree-view/_tree-view.clarity.scss @@ -12,11 +12,21 @@ @include meta.load-css('properties.tree-view'); @include mixins.exports('tree-view.clarity') { + .example-viewport { + height: 200px; + width: 200px; + border: 1px solid black; + } + + .example-item { + height: 24px; + } + .clr-tree-node { //Display display: block; - &.disabled .clr-tree-node-content-container { + .clr-tree-node-content-container.clr-form-control-disabled { cursor: not-allowed; .clr-treenode-link { @@ -102,7 +112,7 @@ //Dimensions &:first-child { - margin-left: tree-view-variables.$clr-tree-node-touch-target; + // margin-left: tree-view-variables.$clr-tree-node-touch-target; } &.clr-treenode-text-only { @@ -216,7 +226,7 @@ .clr-treenode-children { //Dimensions - margin-left: tree-view-variables.$clr-tree-node-children-margin; + // margin-left: tree-view-variables.$clr-tree-node-children-margin; will-change: height; overflow-y: hidden; } diff --git a/projects/angular/src/data/tree-view/models/tree-node.model.ts b/projects/angular/src/data/tree-view/models/tree-node.model.ts index 2c139f031e..cb12e69afc 100644 --- a/projects/angular/src/data/tree-view/models/tree-node.model.ts +++ b/projects/angular/src/data/tree-view/models/tree-node.model.ts @@ -16,6 +16,7 @@ export abstract class TreeNodeModel { textContent: string; loading$ = new BehaviorSubject(false); selected = new BehaviorSubject(ClrSelectedState.UNSELECTED); + hidden = false; /* * Being able to push this down to the RecursiveTreeNodeModel would require too much work on the angular components @@ -101,6 +102,13 @@ export abstract class TreeNodeModel { } } + hideChildren() { + this.children.forEach(child => { + child.hidden = true; + child.hideChildren(); + }); + } + private computeSelectionStateFromChildren() { let oneSelected = false; let oneUnselected = false; diff --git a/projects/angular/src/data/tree-view/tree-features.service.ts b/projects/angular/src/data/tree-view/tree-features.service.ts index 2152e4ec03..083b90bc09 100644 --- a/projects/angular/src/data/tree-view/tree-features.service.ts +++ b/projects/angular/src/data/tree-view/tree-features.service.ts @@ -5,21 +5,35 @@ * The full license information can be found in LICENSE in the root directory of this project. */ +import { DomPortal } from '@angular/cdk/portal'; import { Injectable, Optional, SkipSelf, TemplateRef } from '@angular/core'; import { Subject } from 'rxjs'; import { RecursiveTreeNodeModel } from './models/recursive-tree-node.model'; import { ClrRecursiveForOfContext } from './recursive-for-of'; +export interface PortalKeeper { + position: number; + portal: DomPortal; +} + @Injectable() export class TreeFeaturesService { selectable = false; eager = true; + flat = false; + rawPortals: PortalKeeper[] = []; + recursion: { template: TemplateRef>; root: RecursiveTreeNodeModel[]; }; + childrenFetched = new Subject(); + + get portals() { + return this.rawPortals.sort((a, b) => a.position - b.position).map(portal => portal.portal); + } } export function treeFeaturesFactory(existing: TreeFeaturesService) { diff --git a/projects/angular/src/data/tree-view/tree-node.html b/projects/angular/src/data/tree-view/tree-node.html index 763874a8bf..be1e09a915 100644 --- a/projects/angular/src/data/tree-view/tree-node.html +++ b/projects/angular/src/data/tree-view/tree-node.html @@ -4,73 +4,77 @@ ~ This software is released under MIT license. ~ The full license information can be found in LICENSE in the root directory of this project. --> - -
- -
- -
-
- +
+
- -
-
- -
+ [class.clr-form-control-disabled]="disabled" + [attr.aria-disabled]="disabled" + [attr.aria-expanded]="isExpandable() ? expanded : null" + [attr.aria-selected]="ariaSelected" + (keydown)="onKeyDown($event)" + (focus)="broadcastFocusOnContainer()" + [style.marginLeft.rem]="distanceFromRoot" + > + +
+ +
+
+ + +
+
+ +
- - -
- selected - unselected + + +
+ selected + unselected +
+
+ ({{distanceFromRoot}} {{positionInTree}})
-
-
-
+
--> +
diff --git a/projects/angular/src/data/tree-view/tree-node.ts b/projects/angular/src/data/tree-view/tree-node.ts index aae634067d..6b71d95b87 100644 --- a/projects/angular/src/data/tree-view/tree-node.ts +++ b/projects/angular/src/data/tree-view/tree-node.ts @@ -6,6 +6,7 @@ */ import { animate, state, style, transition, trigger } from '@angular/animations'; +import { DomPortal } from '@angular/cdk/portal'; import { isPlatformBrowser } from '@angular/common'; import { AfterContentInit, @@ -38,6 +39,7 @@ import { LoadingListener } from '../../utils/loading/loading-listener'; import { DeclarativeTreeNodeModel } from './models/declarative-tree-node.model'; import { ClrSelectedState } from './models/selected-state.enum'; import { TreeNodeModel } from './models/tree-node.model'; +import { ClrTree } from './tree'; import { TREE_FEATURES_PROVIDER, TreeFeaturesService } from './tree-features.service'; import { TreeFocusManagerService } from './tree-focus-manager.service'; import { ClrTreeNodeLink } from './tree-node-link'; @@ -78,13 +80,14 @@ export class ClrTreeNode implements OnInit, AfterContentInit, AfterViewInit, nodeId = uniqueIdFactory(); contentContainerTabindex = -1; _model: TreeNodeModel; + @ViewChild('portalContent', { read: ElementRef }) portalElement: ElementRef; private skipEmitChange = false; private typeAheadKeyBuffer = ''; private typeAheadKeyEvent = new Subject(); private subscriptions: Subscription[] = []; - - @ViewChild('contentContainer', { read: ElementRef, static: true }) private contentContainer: ElementRef; + private flatPosition = 0; + @ViewChild('contentContainer', { read: ElementRef }) private contentContainer: ElementRef; // @ContentChild would have been more succinct // but it doesn't offer a way to query only an immediate child @@ -95,6 +98,7 @@ export class ClrTreeNode implements OnInit, AfterContentInit, AfterViewInit, @Optional() @SkipSelf() parent: ClrTreeNode, + public root: ClrTree, public featuresService: TreeFeaturesService, public expandService: IfExpandService, public commonStrings: ClrCommonStringsService, @@ -184,10 +188,74 @@ export class ClrTreeNode implements OnInit, AfterContentInit, AfterViewInit, return this.treeNodeLinkList && this.treeNodeLinkList.first; } + get distanceFromRoot() { + let distance = 0; + let parent = this._model.parent; + while (parent !== null) { + distance++; + parent = parent.parent; + } + return distance; + } + + get positionInTree() { + let position = 0; + let node = this._model; + + if (node.parent === null) { + // For the root noded, there is no parent, so we take the siblings from the pre-saved rootNodeModels + position += this.findPositionAmongSiblings( + node, + this.root.rootNodes?.map(node => node._model) + ); + } else { + while (node.parent !== null) { + position++; + position += this.findPositionAmongSiblings(node, node.parent?.children || []); + node = node.parent; + } + } + + return position; + } + private get isParent() { return this._model.children && this._model.children.length > 0; } + findPositionAmongSiblings(node: TreeNodeModel, siblings: TreeNodeModel[]) { + let position = 0; + + // let siblings = allSiblings.filter(sibling => !sibling.hidden); + + if (siblings) { + // If these siblings do not contain the current node, we need to add them all to the count. + // This is adjacent branch - children of a parent sibling that preceeds us. + if (siblings.indexOf(node) === -1) { + return siblings.length; + } + + for (let i = 0; i < siblings.length; i++) { + if (siblings[i] === node) { + break; + } else { + position++; + position += this.countChildren(siblings[i]); + } + } + } + return position; + } + + countChildren(node) { + let result = 0; + result += node.children?.length || 0; + node.children?.forEach(child => { + result += this.countChildren(child); + }); + return result; + } + ngOnInit() { this._model.expanded = this.expanded; this._model.disabled = this.disabled; @@ -216,6 +284,11 @@ export class ClrTreeNode implements OnInit, AfterContentInit, AfterViewInit, this.subscriptions.push( this._model.loading$.pipe(debounceTime(0)).subscribe(isLoading => (this.isModelLoading = isLoading)) ); + + // We need to save the position, so we can insert the item before its children. + // this.flatPosition = this.featuresService.portals.length; + + this.updateVisibility(this._model); } ngAfterContentInit() { @@ -232,6 +305,9 @@ export class ClrTreeNode implements OnInit, AfterContentInit, AfterViewInit, if (!this._model.textContent) { this._model.textContent = trimAndLowerCase(this.elementRef.nativeElement.textContent); } + const portal = new DomPortal(this.portalElement.nativeElement); + // this.featuresService.portals.splice(this.positionInTree, 0, portal); + this.featuresService.rawPortals.push({ position: this.positionInTree, portal: portal }); } ngOnDestroy() { @@ -259,6 +335,11 @@ export class ClrTreeNode implements OnInit, AfterContentInit, AfterViewInit, } } + toggle() { + this.expandService.toggle(); + this.updateVisibility(this._model); + } + broadcastFocusOnContainer() { this.focusManager.broadcastFocusedNode(this.nodeId); } @@ -337,6 +418,24 @@ export class ClrTreeNode implements OnInit, AfterContentInit, AfterViewInit, } } + private updateVisibility(node: TreeNodeModel) { + if (node.expanded && !node.hidden) { + node.children.forEach(child => { + child.hidden = false; + if (child.children.length > 0 && child.expanded) { + this.updateVisibility(child); + } + }); + } else { + node.children.forEach(child => { + child.hidden = true; + if (child.children.length > 0) { + this.updateVisibility(child); + } + }); + } + } + private expandOrFocusFirstChild() { if (this.disabled) { return; diff --git a/projects/angular/src/data/tree-view/tree-view.module.ts b/projects/angular/src/data/tree-view/tree-view.module.ts index e286616d45..cb8b10fe85 100644 --- a/projects/angular/src/data/tree-view/tree-view.module.ts +++ b/projects/angular/src/data/tree-view/tree-view.module.ts @@ -5,6 +5,8 @@ * The full license information can be found in LICENSE in the root directory of this project. */ +import { PortalModule } from '@angular/cdk/portal'; +import { ScrollingModule } from '@angular/cdk/scrolling'; import { CommonModule } from '@angular/common'; import { NgModule, Type } from '@angular/core'; import { angleIcon, ClarityIcons } from '@cds/core/icon'; @@ -20,7 +22,7 @@ import { ClrTreeNodeLink } from './tree-node-link'; export const CLR_TREE_VIEW_DIRECTIVES: Type[] = [ClrTree, ClrTreeNode, ClrRecursiveForOf, ClrTreeNodeLink]; @NgModule({ - imports: [CommonModule, ClrIconModule, ClrLoadingModule], + imports: [CommonModule, ClrIconModule, ClrLoadingModule, PortalModule, ScrollingModule], declarations: [CLR_TREE_VIEW_DIRECTIVES, RecursiveChildren], exports: [CLR_TREE_VIEW_DIRECTIVES], }) diff --git a/projects/angular/src/data/tree-view/tree.ts b/projects/angular/src/data/tree-view/tree.ts index 671e8fc926..b24fc9c80a 100644 --- a/projects/angular/src/data/tree-view/tree.ts +++ b/projects/angular/src/data/tree-view/tree.ts @@ -5,6 +5,7 @@ * The full license information can be found in LICENSE in the root directory of this project. */ +import { CdkPortalOutlet } from '@angular/cdk/portal'; import { AfterContentInit, Component, @@ -15,6 +16,7 @@ import { OnDestroy, QueryList, Renderer2, + ViewChild, } from '@angular/core'; import { fromEvent, Subscription } from 'rxjs'; @@ -25,11 +27,24 @@ import { ClrTreeNode } from './tree-node'; @Component({ selector: 'clr-tree', template: ` - + {{ rootNodes }} + {{ dumpdump() | json }} + +
+ +
+
+ + + + `, providers: [TREE_FEATURES_PROVIDER, TreeFocusManagerService], host: { @@ -37,8 +52,10 @@ import { ClrTreeNode } from './tree-node'; '[attr.role]': '"tree"', }, }) -export class ClrTree implements AfterContentInit, OnDestroy { - @ContentChildren(ClrTreeNode) private rootNodes: QueryList>; +export class ClrTree implements AfterContentInit, /*AfterViewInit,*/ OnDestroy { + @ContentChildren(ClrTreeNode) rootNodes: QueryList>; + + @ViewChild(CdkPortalOutlet) portalOutlet!: CdkPortalOutlet; private subscriptions: Subscription[] = []; private _isMultiSelectable = false; @@ -71,6 +88,11 @@ export class ClrTree implements AfterContentInit, OnDestroy { this.featuresService.eager = !value; } + @Input('clrFlat') + set flatTree(value: boolean) { + this.featuresService.flat = value; + } + get isMultiSelectable() { return this._isMultiSelectable; } @@ -86,10 +108,20 @@ export class ClrTree implements AfterContentInit, OnDestroy { ); } + ngAfterViewInit() { + this.featuresService.portals?.forEach(p => { + console.log(p.element); + }); + } + ngOnDestroy() { this.subscriptions.forEach(sub => sub.unsubscribe()); } + dumpdump() { + return this.featuresService.rawPortals.map(p => p.position); + } + private setMultiSelectable() { if (this.featuresService.selectable && this.rootNodes.length > 0) { this._isMultiSelectable = true;