Skip to content

Commit 03cfe38

Browse files
committed
feat(design)!: convert tree to signals (#4408)
This also brings three new features: - Node IDs are now scoped to each tree instance: ids are composed as `<treeId>.<id>` down the ancestor chain. - `DaffTreeComponent` exposes an `id` input with an auto-incrementing fallback so multiple trees on the same page nlonger collide on element IDs. - When a tree item becomes `selected`, the item opens its full ancestor chain via the new `daffTreeOpenAncestors` helper and notifies the tree to re-flatten, so selected items are revealed without consumers having to call `open` manually. BREAKING CHANGE: `DaffTreeComponent` and `DaffTreeItemDirective` now expose their inputs (`tree`, `renderMode`, `node`, `selected`), content queries (`withChildrenTemplate`, `treeItemTemplate`), and the public `flatTree` field as signals rather than plain properties. Template bindings (`[tree]="data"`) continue to work, but any programmatic reads must invoke the signal: `component.flatTree` → `component.flatTree()`, `component.tree` → `component.tree()`,`directive.node` → `directive.node()`, and so on. Consumers that read these fields from tests or component code must update accordingly.
1 parent eaa0d7c commit 03cfe38

10 files changed

Lines changed: 189 additions & 128 deletions

File tree

libs/design/tree/src/tree-item/tree-item.directive.ts

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
/* eslint-disable quote-props */
22

3+
import { DOCUMENT } from '@angular/common';
34
import {
5+
computed,
46
Directive,
5-
Inject,
6-
Input,
7-
DOCUMENT,
7+
effect,
8+
inject,
9+
input,
10+
untracked,
811
} from '@angular/core';
912

1013
import { DaffTreeNotifierService } from '../tree/tree-notifier.service';
1114
import { DaffTreeFlatNode } from '../utils/flatten-tree';
15+
import { daffTreeOpenAncestors } from '../utils/open-ancestors';
1216

1317
/**
1418
* The `DaffTreeItemDirective` marks elements as tree child nodes that interact with the parent tree structure.
@@ -34,89 +38,86 @@ import { DaffTreeFlatNode } from '../utils/flatten-tree';
3438
selector: '[daffTreeItem]',
3539
host: {
3640
'class': 'daff-tree-item',
37-
'[class.selected]': 'selected',
38-
'[class.parent]': 'isParent',
39-
'[class.open]': 'open',
40-
'[attr.id]': 'id',
41-
'[attr.aria-expanded]': 'ariaExpanded',
42-
'[style.--depth]': 'depth',
41+
'[class.selected]': 'selected()',
42+
'[class.parent]': 'isParent()',
43+
'[class.open]': 'open()',
44+
'[attr.id]': 'id()',
45+
'[attr.aria-expanded]': 'ariaExpanded()',
46+
'[style.--depth]': 'depth()',
4347
'(keydown.escape)': 'onEscape()',
4448
'(click)': 'onClick()',
4549
},
4650
})
4751
export class DaffTreeItemDirective {
48-
private isParent = false;
52+
private document = inject(DOCUMENT);
53+
private treeNotifier = inject(DaffTreeNotifierService);
4954

5055
/**
51-
* The html `id` of the tree item. This is derived from the {@link DaffTreeData}.
52-
*
56+
* The {@link DaffTreeFlatNode} associated with this specific tree item.
5357
*/
54-
private id: string;
58+
readonly node = input.required<DaffTreeFlatNode>();
5559

5660
/**
57-
* Accessibility property, notifying users about whether
58-
* or not the tree item is open.
61+
* Whether or not the tree item is the currently active item.
62+
* Note that there is no requirement that there only be one active item at a time.
63+
*
64+
* When a tree item becomes selected, all of its ancestor nodes
65+
* will be automatically opened so that the selected item is visible.
5966
*/
60-
private ariaExpanded: string;
67+
readonly selected = input(false);
6168

6269
/**
63-
* A property indicating the depth of the tree.
70+
* The html `id` of the tree item. This is derived from the {@link DaffTreeData}.
6471
*/
65-
private depth: number;
72+
protected readonly id = computed(() => 'tree-' + this.node().id);
6673

6774
/**
68-
* Indicates whether or not the tree is `open`.
75+
* A property indicating the depth of the tree.
6976
*/
70-
private open = false;
77+
protected readonly depth = computed(() => this.node().level);
7178

7279
/**
73-
* The {@link DaffTreeFlatNode} associated with this specific tree item.
80+
* Whether or not this node has children.
7481
*/
75-
private _node: DaffTreeFlatNode;
82+
protected readonly isParent = computed(() => this.node().hasChildren);
7683

7784
/**
78-
* The {@link DaffTreeFlatNode} associated with this specific tree item.
85+
* Indicates whether or not the tree is `open`.
7986
*/
80-
@Input()
81-
get node() {
82-
return this._node;
83-
};
84-
set node(val: DaffTreeFlatNode) {
85-
this._node = val;
86-
this.id = 'tree-' + this._node.id;
87-
this.depth = this._node.level;
88-
this.isParent = this._node.hasChildren;
89-
this.open = this._node._treeRef.open;
90-
91-
if(this._node.hasChildren) {
92-
this.ariaExpanded = this._node._treeRef.open ? 'true' : 'false';
93-
}
94-
}
87+
protected readonly open = computed(() => this.node()._treeRef.open);
9588

9689
/**
97-
* Whether or not the tree item is the currently active item.
98-
* Note that there is no requirement that there only be one active item at a time.
90+
* Accessibility property, notifying users about whether
91+
* or not the tree item is open.
9992
*/
100-
@Input() selected = false;
101-
102-
constructor(
103-
@Inject(DOCUMENT) private document: any,
104-
private treeNotifier: DaffTreeNotifierService,
105-
) {}
93+
protected readonly ariaExpanded = computed(() => {
94+
const node = this.node();
95+
return node.hasChildren ? (node._treeRef.open ? 'true' : 'false') : undefined;
96+
});
97+
98+
constructor() {
99+
effect(() => {
100+
if(this.selected()) {
101+
const node = untracked(this.node);
102+
daffTreeOpenAncestors(node._treeRef);
103+
this.treeNotifier.notify();
104+
}
105+
});
106+
}
106107

107108
/**
108109
* @docs-private
109110
*/
110111
onEscape() {
111-
this.toggleParent(this.node);
112+
this.toggleParent(this.node());
112113
}
113114

114115
/**
115116
* @docs-private
116117
*/
117118
onClick() {
118-
if(this.node.hasChildren) {
119-
this.toggleTree(this.node);
119+
if(this.node().hasChildren) {
120+
this.toggleTree(this.node());
120121
}
121122
this.treeNotifier.notify();
122123
}

libs/design/tree/src/tree/specs/defaults.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('@daffodil/design/tree | DaffTreeComponent | Defaults', () => {
3333
});
3434

3535
it('should have sane defaults', () => {
36-
expect(component.flatTree).toEqual([]);
37-
expect(component.tree).toEqual(undefined);
36+
expect(component.flatTree()).toEqual([]);
37+
expect(component.tree()).toEqual(undefined);
3838
});
3939
});

libs/design/tree/src/tree/specs/render-modes.spec.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
Component,
3-
Input,
3+
signal,
44
} from '@angular/core';
55
import {
66
ComponentFixture,
@@ -15,7 +15,7 @@ import { DaffTreeComponent } from '../tree.component';
1515

1616
@Component({
1717
template: `
18-
<ul daff-tree [tree]="data" [renderMode]="renderMode">
18+
<ul daff-tree [tree]="data()" [renderMode]="renderMode()">
1919
<ng-template #daffTreeItemWithChildrenTpl let-node>
2020
<button daffTreeItem [node]="node">{{ node.title }} </button>
2121
</ng-template>
@@ -31,8 +31,8 @@ import { DaffTreeComponent } from '../tree.component';
3131
],
3232
})
3333
class WrapperComponent {
34-
@Input() data: DaffTreeData<any>;
35-
@Input() renderMode: DaffTreeRenderMode;
34+
data = signal<DaffTreeData<any>>(undefined);
35+
renderMode = signal<DaffTreeRenderMode>(undefined);
3636
}
3737

3838

@@ -62,25 +62,25 @@ describe('@daffodil/design/tree | DaffTreeComponent | renderModes', () => {
6262
});
6363

6464
it('should render two nodes when renderMode is `not-in-dom`', () => {
65-
wrapper.data = { title: 'Root', url: '', id: '', items: [
65+
wrapper.data.set({ title: 'Root', url: '', id: '', items: [
6666
{ title: 'Child A', url: '', id: '', items: [
6767
{ title: 'Child Aa', url: '', id: '', items: [], data: {}},
6868
], data: {}},
6969
{ title: 'Child B', url: '', id: '', items: [], data: {}},
70-
], data: {}};
71-
wrapper.renderMode = 'not-in-dom';
70+
], data: {}});
71+
wrapper.renderMode.set('not-in-dom');
7272
fixture.detectChanges();
7373
expect(fixture.debugElement.queryAll(By.css('li')).length).toEqual(2);
7474
});
7575

7676
it('should render three nodes when renderMode is `in-dom`', () => {
77-
wrapper.data = { title: 'Root', url: '', id: '', items: [
77+
wrapper.data.set({ title: 'Root', url: '', id: '', items: [
7878
{ title: 'Child A', url: '', id: '', items: [
7979
{ title: 'Child Aa', url: '', id: '', items: [], data: {}},
8080
], data: {}},
8181
{ title: 'Child B', url: '', id: '', items: [], data: {}},
82-
], data: {}};
83-
wrapper.renderMode = 'in-dom';
82+
], data: {}});
83+
wrapper.renderMode.set('in-dom');
8484
fixture.detectChanges();
8585
expect(fixture.debugElement.queryAll(By.css('li')).length).toEqual(3);
8686
});

libs/design/tree/src/tree/specs/simple.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
Component,
3-
Input,
3+
signal,
44
} from '@angular/core';
55
import {
66
ComponentFixture,
@@ -13,14 +13,14 @@ import { DaffTreeComponent } from '../tree.component';
1313

1414
@Component({
1515
template: `
16-
<ul daff-tree [tree]="data"></ul>
16+
<ul daff-tree [tree]="data()"></ul>
1717
`,
1818
imports: [
1919
DaffTreeComponent,
2020
],
2121
})
2222
class WrapperComponent {
23-
@Input() data: DaffTreeData<any>;
23+
data = signal<DaffTreeData<any>>(undefined);
2424
}
2525

2626
describe('@daffodil/design/tree | DaffTreeComponent | Simple', () => {
@@ -54,7 +54,7 @@ describe('@daffodil/design/tree | DaffTreeComponent | Simple', () => {
5454
});
5555

5656
it('should render nothing within the tree when data is provided with no templates', () => {
57-
wrapper.data = { title: '', url: '', id: '', items: [], data: {}};
57+
wrapper.data.set({ title: '', url: '', id: '', items: [], data: {}});
5858
fixture.detectChanges();
5959

6060
expect(el).toBeTruthy();

libs/design/tree/src/tree/specs/with-template.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
Component,
3-
Input,
3+
signal,
44
} from '@angular/core';
55
import {
66
ComponentFixture,
@@ -14,7 +14,7 @@ import { DaffTreeComponent } from '../tree.component';
1414

1515
@Component({
1616
template: `
17-
<ul daff-tree [tree]="data">
17+
<ul daff-tree [tree]="data()">
1818
<ng-template #daffTreeItemWithChildrenTpl let-node>
1919
<button daffTreeItem [node]="node">{{ node.title }} </button>
2020
</ng-template>
@@ -30,7 +30,7 @@ import { DaffTreeComponent } from '../tree.component';
3030
],
3131
})
3232
class WrapperComponent {
33-
@Input() data: DaffTreeData<any>;
33+
data = signal<DaffTreeData<any>>(undefined);
3434
}
3535

3636

@@ -60,23 +60,23 @@ describe('@daffodil/design/tree - DaffTreeComponent | withTemplate', () => {
6060
});
6161

6262
it('should render something when data and templates are provided', () => {
63-
wrapper.data = { title: 'Root', url: '', id: '', items: [
63+
wrapper.data.set({ title: 'Root', url: '', id: '', items: [
6464
{ title: 'Child A', url: '', id: '', items: [
6565
{ title: 'Child Aa', url: '', id: '', items: [], data: {}},
6666
], data: {}},
6767
{ title: 'Child B', url: '', id: '', items: [], data: {}},
68-
], data: {}};
68+
], data: {}});
6969
fixture.detectChanges();
7070
expect(fixture.debugElement.query(By.css('li')).componentInstance instanceof DaffTreeComponent).toBeTrue();
7171
});
7272

7373
it('should render the same number of items as there are tree branches', () => {
74-
wrapper.data = { title: 'Root', url: '', id: '', items: [
74+
wrapper.data.set({ title: 'Root', url: '', id: '', items: [
7575
{ title: 'Child A', url: '', id: '', items: [
7676
{ title: 'Child Aa', url: '', id: '', items: [], data: {}},
7777
], data: {}},
7878
{ title: 'Child B', url: '', id: '', items: [], data: {}},
79-
], data: {}};
79+
], data: {}});
8080
fixture.detectChanges();
8181
expect(fixture.debugElement.queryAll(By.css('li')).length).toEqual(3);
8282
});
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
@for (node of flatTree; track node.id) {
1+
@for (node of flatTree(); track node.id) {
22
<li [attr.aria-level]="node.level" [class.hidden]="!node.visible">
33
<ng-container
4-
*ngTemplateOutlet="node.hasChildren ? withChildrenTemplate : treeItemTemplate; context: { $implicit: node }">
4+
*ngTemplateOutlet="node.hasChildren ? withChildrenTemplate() : treeItemTemplate(); context: { $implicit: node }">
55
</ng-container>
66
</li>
77
}

0 commit comments

Comments
 (0)