Skip to content

Commit 5c48649

Browse files
committed
feat: Add treeview implementation
Signed-off-by: Akshat Patel <akshat@live.ca>
1 parent 80cc7cd commit 5c48649

File tree

5 files changed

+509
-0
lines changed

5 files changed

+509
-0
lines changed

src/treeview/tree-node.component.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import {
2+
Component,
3+
Input,
4+
Output,
5+
EventEmitter,
6+
OnInit,
7+
OnDestroy,
8+
AfterContentInit,
9+
TemplateRef,
10+
AfterContentChecked
11+
} from "@angular/core";
12+
import { Subscription } from "rxjs";
13+
import { TreeViewService } from "./treeview.service";
14+
import { Node } from "./tree-node.types";
15+
16+
@Component({
17+
selector: "cds-tree-node",
18+
template: `
19+
<div
20+
[id]="id"
21+
class="cds--tree-node"
22+
[ngClass]="{
23+
'cds--tree-node--active': active,
24+
'cds--tree-node--disabled': disabled,
25+
'cds--tree-node--selected': selected,
26+
'cds--tree-leaf-node': !children.length,
27+
'cds--tree-parent-node': children.length,
28+
'cds--tree-node--with-icon': icon
29+
}"
30+
[attr.aria-expanded]="expanded || null"
31+
[attr.aria-current]="active || null"
32+
[attr.aria-selected]="disabled ? null : selected"
33+
[attr.aria-disabled]="disabled"
34+
role="treeitem"
35+
[attr.tabindex]="selected ? 0 : -1"
36+
(focus)="emitFocusEvent($event)"
37+
(blur)="emitBlurEvent($event)"
38+
(keydown)="navigateTree($event)">
39+
<div
40+
*ngIf="!children.length"
41+
class="cds--tree-node__label"
42+
[style.padding-inline-start.rem]="offset"
43+
[style.margin-inline-start.rem]="-offset"
44+
(click)="nodeClick($event)">
45+
<!-- Icon -->
46+
<ng-container *ngIf="icon && !isTemplate(icon)">
47+
<svg
48+
class="cds--tree-node__icon"
49+
[cdsIcon]="icon"
50+
size="16">
51+
</svg>
52+
</ng-container>
53+
<ng-template *ngIf="isTemplate(icon)" [ngTemplateOutlet]="icon"></ng-template>
54+
{{label}}
55+
</div>
56+
<div
57+
*ngIf="children.length"
58+
class="cds--tree-node__label"
59+
[style.padding-inline-start.rem]="offset"
60+
[style.margin-inline-start.rem]="-offset"
61+
role="group"
62+
(click)="nodeClick($event)">
63+
<span
64+
class="cds--tree-parent-node__toggle"
65+
[attr.disabled]="disabled || null"
66+
(click)="toggleExpanded($event)">
67+
<svg
68+
class="cds--tree-parent-node__toggle-icon"
69+
[ngClass]="{'cds--tree-parent-node__toggle-icon--expanded' : expanded}"
70+
ibmIcon="caret--down"
71+
size="16">
72+
</svg>
73+
</span>
74+
<span class="cds--tree-node__label__details">
75+
<!-- Icon -->
76+
<ng-container *ngIf="icon && !isTemplate(icon)">
77+
<svg
78+
class="cds--tree-node__icon"
79+
[cdsIcon]="icon"
80+
size="16">
81+
</svg>
82+
</ng-container>
83+
<ng-template *ngIf="isTemplate(icon)" [ngTemplateOutlet]="icon"></ng-template>
84+
{{label}}
85+
</span>
86+
</div>
87+
<div
88+
*ngIf="expanded"
89+
role="group"
90+
class="cds--tree-node__children">
91+
<ng-container *ngIf="isProjected(); else notProjected">
92+
<ng-content></ng-content>
93+
</ng-container>
94+
<ng-template #notProjected>
95+
<cds-tree-node
96+
*ngFor="let childNode of children"
97+
[node]="childNode"
98+
[depth]="depth + 1"
99+
[disabled]="disabled">
100+
</cds-tree-node>
101+
</ng-template>
102+
</div>
103+
</div>
104+
`
105+
})
106+
export class TreeNodeComponent implements AfterContentChecked, OnInit, OnDestroy {
107+
static treeNodeCount = 0;
108+
@Input() id = `tree-node-${TreeNodeComponent.treeNodeCount++}`;
109+
@Input() active = false;
110+
@Input() disabled = false;
111+
@Input() expanded = false;
112+
@Input() label: string | TemplateRef<any>;
113+
@Input() selected = false;
114+
@Input() value;
115+
@Input() icon: string | TemplateRef<any>;
116+
@Input() children: Node[] = [];
117+
118+
/**
119+
* Determines the depth of the node
120+
* Calculated by default when passing `Node` array to `TreeViewComponent`, manual entry required otherwise
121+
*/
122+
@Input() depth = 0;
123+
124+
/**
125+
* Simple way to set all attributes of Node component via node object
126+
* Would simplify setting component attributes when dynamically rendering node.
127+
*/
128+
@Input() set node(node: Node) {
129+
this._node = node;
130+
131+
this.id = node.id ?? this.id;
132+
this.active = node.active ?? this.active;
133+
this.disabled = node.disabled ?? this.disabled;
134+
this.expanded = node.expanded ?? this.expanded;
135+
this.label = node.label ?? this.label;
136+
this.value = node.value ?? this.value;
137+
this.icon = node.icon ?? this.icon;
138+
this.selected = node.selected ?? this.selected;
139+
this.depth = node.depth ?? this.depth;
140+
this.children = node.children ?? this.children;
141+
}
142+
143+
get node() {
144+
return this._node;
145+
}
146+
147+
@Output() nodeFocus = new EventEmitter();
148+
@Output() nodeBlur = new EventEmitter();
149+
@Output() nodeSelect = new EventEmitter();
150+
@Output() nodetoggle = new EventEmitter();
151+
152+
offset;
153+
private _node;
154+
private subscription: Subscription;
155+
156+
constructor(private treeViewService: TreeViewService) {}
157+
158+
/**
159+
* Caclulate offset for margin/padding
160+
*/
161+
ngAfterContentChecked(): void {
162+
this.offset = this.calculateOffset();
163+
}
164+
165+
/**
166+
* Highlight the node
167+
*/
168+
ngOnInit(): void {
169+
// Highlight the node
170+
this.subscription = this.treeViewService.selectionObservable.subscribe((value: Map<string, Node>) => {
171+
this.selected = value.has(this.id);
172+
this.active = this.selected;
173+
});
174+
}
175+
176+
/**
177+
* Unsubscribe from subscriptions
178+
*/
179+
ngOnDestroy(): void {
180+
this.subscription?.unsubscribe();
181+
}
182+
183+
/**
184+
* Selects the node and emits the event from the tree view component
185+
* @param event
186+
*/
187+
nodeClick(event) {
188+
if (!this.disabled) {
189+
this.selected = true;
190+
this.active = true;
191+
event.target.parentElement.focus();
192+
// Passes event to all nodes to update highlighting & parent to emit
193+
this.treeViewService.selectNode({ id: this.id, label: this.label, value: this.value });
194+
}
195+
}
196+
197+
/**
198+
* Calculate the node offset
199+
* @returns Number
200+
*/
201+
calculateOffset() {
202+
// Parent node with icon
203+
if (this.children.length && this.icon) {
204+
return this.depth + 1 + this.depth * 0.5;
205+
}
206+
207+
// parent node without icon
208+
if (this.children.length) {
209+
return this.depth + 1;
210+
}
211+
212+
// leaf node with icon
213+
if (this.icon) {
214+
return this.depth + 2 + this.depth * 0.5;
215+
}
216+
217+
return this.depth + 2.5;
218+
}
219+
220+
emitFocusEvent(event) {
221+
this.nodeFocus.emit({ node: { id: this.id, label: this.label, value: this.value }, event });
222+
}
223+
224+
emitBlurEvent(event) {
225+
this.nodeBlur.emit({ node: { id: this.id, label: this.label, value: this.value }, event });
226+
}
227+
228+
/**
229+
* Expand children if not disabled
230+
* @param event: Event
231+
*/
232+
toggleExpanded(event) {
233+
if (!this.disabled) {
234+
this.nodetoggle.emit({ node: { id: this.id, label: this.label, value: this.value }, event });
235+
this.expanded = !this.expanded;
236+
// Prevent selection of the node
237+
event.stopPropagation();
238+
}
239+
}
240+
241+
/**
242+
* Manages the keyboard accessibility for children expansion & selection
243+
*/
244+
navigateTree(event: KeyboardEvent) {
245+
if (event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Enter") {
246+
event.stopPropagation();
247+
}
248+
// Unexpand
249+
if (event.key === "ArrowLeft") {
250+
if (this.expanded && this.children) {
251+
this.toggleExpanded(event);
252+
}
253+
}
254+
255+
if (event.key === "ArrowRight") {
256+
if (!this.expanded && this.children) {
257+
this.toggleExpanded(event);
258+
}
259+
}
260+
261+
if (event.key === "Enter") {
262+
event.preventDefault();
263+
this.nodeClick(event);
264+
}
265+
}
266+
267+
public isTemplate(value) {
268+
return value instanceof TemplateRef;
269+
}
270+
271+
public isProjected() {
272+
return this.treeViewService.contentProjected;
273+
}
274+
}

src/treeview/tree-node.types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { TemplateRef } from "@angular/core";
2+
3+
export interface Node {
4+
label: string | TemplateRef<any>;
5+
value?: any;
6+
id?: string;
7+
active?: boolean;
8+
disabled?: boolean;
9+
expanded?: boolean;
10+
selected?: boolean;
11+
icon?: string | TemplateRef<any>;
12+
children?: Node[];
13+
[key: string]: any;
14+
}

0 commit comments

Comments
 (0)