Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(design): add a tree component #1622

Merged
merged 1 commit into from
Jul 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,7 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "libs/design/src/test.ts",
"main": "libs/design/test.ts",
"codeCoverage": true,
"tsConfig": "libs/design/tsconfig.spec.json",
"karmaConfig": "libs/design/karma.conf.js",
Expand Down
2 changes: 2 additions & 0 deletions libs/design/scss/theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
@use '../src/molecules/sidebar/sidebar/sidebar-theme' as sidebar;
@use '../src/molecules/sidebar/sidebar-viewport/sidebar-viewport-theme' as sidebar-viewport;
@use '../scss/state/skeleton/mixins' as skeleton;
@use '../tree/src/tree-theme' as tree;

//
// Generates the styles of a @daffodil/design theme.
Expand Down Expand Up @@ -79,4 +80,5 @@
@include paginator.daff-paginator-theme($theme);
@include sidebar.daff-sidebar-theme($theme);
@include sidebar-viewport.daff-sidebar-viewport-theme($theme);
@include tree.daff-tree-theme($theme);
}
File renamed without changes.
38 changes: 38 additions & 0 deletions libs/design/tree/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Tree

Trees are used to visualize hierarchial information. They are often used to display navigational structures like nested lists of links.

## Overview

The `DaffTreeComponent` renders a tree structure. Typically, this is a structure of `<a>` and `<button>` elements that allow users to either navigate to a page, or explore the tree to find an item inside the tree that they want to navigate to.

Instead of defining a recursive tree structure of components, which is often prohibitively slow when rendering large trees, the `DaffTreeComponent` renders a flattened tree, using padding to indicate the nesting level of the tree elements.

Generally, tree usage consists of taking existing tree data, converting it to the `DaffTreeData` format, setting the `tree` input on the `DaffTreeComponent`, and providing templates for the cases where the tree element has children or not.

## Features

The `DaffTreeComponent` controls the rendering of the structure of the tree and provides template slots so that you can control the ultimate UI rendered for each node.

Currently, we support two kind of templates `daffTreeItemWithChildrenTpl` and `daffTreeItemTpl`. These templates allow you to control the content of each tree node. In the case of `daffTreeItemWithChildrenTpl` a `click` handler will be automatically applied (along with an icon indicating the expanded state) to the template to allow users to automatically open and close the node.

```html
<ng-template #daffTreeItemWithChildrenTpl let-node>
<button daffTreeItem [node]="node">{{ node.title }} </button>
</ng-template>

<ng-template #daffTreeItemTpl let-node>
<a daffTreeItem [node]="node" [routerLink]="node.url">{{ node.title }}</a>
</ng-template>
```

## Usage

### Basic Tree

<design-land-example-viewer-container example="basic-tree">
</design-land-example-viewer-container>

## Accessibility

The `DaffTreeComponent` follows the specification for a [disclosure navigation menu](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/) instead of a [tree view](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).
9 changes: 9 additions & 0 deletions libs/design/tree/examples/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/design/examples",
"deleteDestPath": false,
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": ["../../src/scss"]
}
}
3 changes: 3 additions & 0 deletions libs/design/tree/examples/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "@daffodil/design/tree/examples"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<ul daff-tree [tree]="tree">
<ng-template #daffTreeItemWithChildrenTpl let-node>
<button daffTreeItem [node]="node">{{ node.title }} </button>
</ng-template>

<ng-template #daffTreeItemTpl let-node>
<a daffTreeItem [node]="node" [routerLink]="node.url">{{ node.title }}</a>
</ng-template>
</ul>

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
ChangeDetectionStrategy,
Component,
} from '@angular/core';

import { DaffTreeData } from '@daffodil/design/tree';

@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'basic-tree',
templateUrl: './basic-tree.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BasicTreeComponent {
tree: DaffTreeData<unknown> = {
title: 'Root',
items: [
{
title: 'Example Children',
items: [
{ title: 'Example Child', url: '#', id: '', items: [], data: {}},
],
url: '#',
id: '',
data: {},
},
{
title: 'Example Link',
items: [],
url: '#',
id: '',
data: {},
},
],
url: '',
id: '',
data: {},
};
}
22 changes: 22 additions & 0 deletions libs/design/tree/examples/src/basic-tree/basic-tree.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';

import { DaffTreeModule } from '@daffodil/design/tree';

import { BasicTreeComponent } from './basic-tree.component';

@NgModule({
declarations: [
BasicTreeComponent,
],
exports: [
BasicTreeComponent,
],
imports: [
RouterModule,
DaffTreeModule,
FontAwesomeModule,
],
})
export class BasicTreeModule { }
1 change: 1 addition & 0 deletions libs/design/tree/examples/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './public_api';
6 changes: 6 additions & 0 deletions libs/design/tree/examples/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { BasicTreeComponent } from './basic-tree/basic-tree.component';
export { BasicTreeModule } from './basic-tree/basic-tree.module';
export { BasicTreeComponent };
export const TREE_EXAMPLES = [
BasicTreeComponent,
];
9 changes: 9 additions & 0 deletions libs/design/tree/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/design/tree",
"deleteDestPath": false,
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": ["../src/scss"]
}
}
3 changes: 3 additions & 0 deletions libs/design/tree/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "@daffodil/design/tree"
}
1 change: 1 addition & 0 deletions libs/design/tree/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './public_api';
3 changes: 3 additions & 0 deletions libs/design/tree/src/interfaces/recursive-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type RecursiveTreeKeyOfType<T> = keyof {
[P in keyof T as T[P] extends T[]? P: never]: T[]
};
13 changes: 13 additions & 0 deletions libs/design/tree/src/interfaces/tree-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* A basic tree type supporting supplemental data on a tree node.
*
* Tree elements are often slightly more than just basic titles and child items.
* There may be other important data that needs to be available at render time.
*/
export interface DaffTreeData<T> {
title: string;
url: string;
id: string;
items: DaffTreeData<T>[];
data: T;
}
12 changes: 12 additions & 0 deletions libs/design/tree/src/interfaces/tree-ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { DaffTreeData } from './tree-data';

/**
* A DaffTreeUi is the internal data structure used during tree rendering.
*
* This is an internal implementation detail type that.
*/
export interface DaffTreeUi<T> extends DaffTreeData<T> {
open: boolean;
items: DaffTreeUi<T>[];
parent: DaffTreeUi<T>;
}
6 changes: 6 additions & 0 deletions libs/design/tree/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { DaffTreeModule } from './tree.module';
export { DaffTreeComponent } from './tree/tree.component';
export { DaffTreeItemDirective } from './tree-item/tree-item.directive';
export { DaffTreeData } from './interfaces/tree-data';
export { DaffTreeUi } from './interfaces/tree-ui';
export { daffTransformTreeInPlace } from './utils/transform-in-place';
8 changes: 8 additions & 0 deletions libs/design/tree/src/tree-item/tree-item.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { DaffTreeItemDirective } from './tree-item.directive';

describe('DaffTreeItemDirective', () => {
it('should create an instance', () => {
// const directive = new DaffTreeItemDirective();
// expect(directive).toBeTruthy();
});
});
161 changes: 161 additions & 0 deletions libs/design/tree/src/tree-item/tree-item.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { DOCUMENT } from '@angular/common';
import {
Directive,
HostBinding,
HostListener,
Inject,
Input,
} from '@angular/core';

import { DaffTreeNotifierService } from '../tree/tree-notifier.service';
import { DaffTreeFlatNode } from '../utils/flatten-tree';

/**
* The `DaffTreeItemDirective` allows you to demarcate the elements which are
* tree-children that interact with the parent tree.
*
* They can be used like:
*
* ```html
* <ul daff-tree [tree]="tree">
* <ng-template #daffTreeItemWithChildrenTpl let-node>
* <button daffTreeItem [node]="node">{{ node.title }} </button>
* </ng-template>
*
* <ng-template #daffTreeItemTpl let-node>
* <a daffTreeItem [node]="node" [routerLink]="node.url">{{ node.title }}</a>
* </ng-template>
* </ul>
* ```
*
* where `tree` is a {@link DaffTreeData} and `daff-tree` is a {@link DaffTreeComponent}.
*
*/
@Directive({
selector: '[daffTreeItem]',
})
export class DaffTreeItemDirective {

/**
* The css class of the daff-tree.
*
* @docs-private
*/
@HostBinding('class.daff-tree-item') class = true;

/**
* The css class of a DaffTreeItemDirective that has children.
*
* @docs-private
*/
@HostBinding('class.daff-tree-item__parent') classParent = false;

/**
* The html `id` of the tree item. This is derived from the {@link DaffTreeData}.
*
* @docs-private
*/
@HostBinding('attr.id') id;

/**
* Accessibility property, notifying users about whether
* or not the tree item is open.
*
* @docs-private
*/
@HostBinding('attr.aria-expanded') ariaExpanded: string;

/**
* A css variable indicating the depth of the tree.
* You can use this to style your templates if you want to
* use different designs at different depths.
*/
@HostBinding('style.--depth') depth: number;

/**
* The CSS class indicating whether or not the tree is `selected`.
*/
@HostBinding('class.selected') get selectedClass() {
return this.selected;
};

/**
* The CSS class indicating whether or not the tree is `open`.
*/
@HostBinding('class.open') openClass = false;

/**
* The {@link DaffTreeFlatNode} associated with this specific tree item.
*
* @docs-private
*/
private _node: DaffTreeFlatNode;

/**
* The {@link DaffTreeFlatNode} associated with this specific tree item.
*/
@Input()
get node() {
return this._node;
};
set node(val: DaffTreeFlatNode) {
this._node = val;
this.id = 'tree-' + this._node.id;
this.ariaExpanded = this._node._treeRef.open ? 'true' : 'false';
this.depth = this._node.level;
this.classParent = this._node.hasChildren;
this.openClass = this._node._treeRef.open;
}

/**
* Whether or not the tree item is the currently active item.
* Note that there is no requirement there there only be one active item at a time.
*/
@Input() selected = false;

constructor(
@Inject(DOCUMENT) private document: any,
private treeNotifier: DaffTreeNotifierService,
) {}

/**
* @docs-private
*/
@HostListener('keydown.escape')
onEscape() {
this.toggleParent(this.node);
}

/**
* @docs-private
*/
@HostListener('click')
onClick() {
if(this.node.hasChildren) {
this.toggleTree(this.node);
}
this.treeNotifier.notify();
}

/**
* Toggle the open state of the tree's parent.
*/
toggleParent(node: DaffTreeFlatNode) {
if(node._treeRef?.parent.parent === undefined) {
return;
}
node._treeRef.parent.open = !node._treeRef.parent.open;
(<Document>this.document).getElementById('tree-' + node._treeRef.parent.id).focus();
}

/**
* Toggle the open state of this specific subtree tree.
*/
toggleTree(node: DaffTreeFlatNode) {
if(node._treeRef.open === false) {
node._treeRef.open = true;
} else {
node._treeRef.open = false;
}
}
}
Loading