diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53c52b6cf98..f60aca7482f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,27 @@ All notable changes for each version of this project will be documented in this
- `onDataPreLoad` -> `dataPreLoad`
### New Features
+- Added `IgxTree` component
+ - Allows users to render hierarchical data in an easy-to-navigate way. The control is **not** data bound and takes a declarative approach, giving users more control over what is being rendered.
+ - Features API for handling selection (bi-state and cascading), node activation, node expansion state.
+ - Features extensive and easy-to-use keyboard navigation, fully compliant with W3 standards.
+ - Code example for a tree contructured from a hierarchical data set:
+ ```html
+
+
+ {{ node.text }}
+
+
+ {{ child.text }}
+
+ {{ leafChild.text }}
+
+
+
+
+ ```
+ - For more information, check out the [README](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/tree/README.md), [specification](https://github.com/IgniteUI/igniteui-angular/wiki/Tree-Specification) and [official documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tree)
+
- `IgxHierarchicalGrid`
- Added support for exporting hierarchical data.
- `IgxForOf`, `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid`
diff --git a/README.md b/README.md
index c4096c3e807..a4b1ad95ec3 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ You can find source files under the [`src`](https://github.com/IgniteUI/igniteui
|tabs|Available|[Readme](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/tabs/README.md)|[Docs](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tabs)|||||
|time picker|Available|[Readme](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/time-picker/README.md)|[Docs](https://www.infragistics.com/products/ignite-ui-angular/angular/components/time-picker)|||||
|toast|Available|[Readme](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/toast/README.md)|[Docs](https://www.infragistics.com/products/ignite-ui-angular/angular/components/toast)|||||
+|tree|Available|[Readme](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/tree/README.md)|[Docs](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tree)|||||
|tree grid|Available|[Readme](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/grids/tree-grid/README.md)|[Docs](https://www.infragistics.com/products/ignite-ui-angular/angular/components/treegrid/tree-grid)|||||
#### Components available in [igniteui-angular-charts](https://www.npmjs.com/package/igniteui-angular-charts)
diff --git a/projects/igniteui-angular/karma.azure.non-grid.conf.js b/projects/igniteui-angular/karma.azure.non-grid.conf.js
index 62ca36a49b3..67609543b24 100644
--- a/projects/igniteui-angular/karma.azure.non-grid.conf.js
+++ b/projects/igniteui-angular/karma.azure.non-grid.conf.js
@@ -23,7 +23,7 @@ module.exports = function (config) {
random: false
},
tagPrefix: '#',
- skipTags: 'hGrid,tGrid,grid,perf'
+ skipTags: 'hGrid,tGrid,grid,perf,treeView'
},
port: 9876,
colors: true,
diff --git a/projects/igniteui-angular/src/lib/banner/banner.component.ts b/projects/igniteui-angular/src/lib/banner/banner.component.ts
index c1196c0156f..972a20da04c 100644
--- a/projects/igniteui-angular/src/lib/banner/banner.component.ts
+++ b/projects/igniteui-angular/src/lib/banner/banner.component.ts
@@ -1,7 +1,6 @@
import { Component, NgModule, EventEmitter, Output, Input, ViewChild, ElementRef,
ContentChild, HostBinding } from '@angular/core';
import { IgxExpansionPanelModule } from '../expansion-panel/expansion-panel.module';
-import { AnimationSettings } from '../expansion-panel/expansion-panel.component';
import { IgxExpansionPanelComponent } from '../expansion-panel/public_api';
import { IgxIconModule, IgxIconComponent } from '../icon/public_api';
import { IToggleView } from '../core/navigation';
@@ -10,6 +9,7 @@ import { IgxRippleModule } from '../directives/ripple/ripple.directive';
import { IgxBannerActionsDirective } from './banner.directives';
import { CommonModule } from '@angular/common';
import { CancelableEventArgs, IBaseEventArgs } from '../core/utils';
+import { ToggleAnimationSettings } from '../expansion-panel/toggle-animation-component';
export interface BannerEventArgs extends IBaseEventArgs {
banner: IgxBannerComponent;
@@ -111,11 +111,11 @@ export class IgxBannerComponent implements IToggleView {
/**
* Get the animation settings used by the banner open/close methods
* ```typescript
- * let currentAnimations: AnimationSettings = banner.animationSettings
+ * let currentAnimations: ToggleAnimationSettings = banner.animationSettings
* ```
*/
@Input()
- public get animationSettings(): AnimationSettings {
+ public get animationSettings(): ToggleAnimationSettings {
return this._animationSettings ? this._animationSettings : this._expansionPanel.animationSettings;
}
@@ -124,10 +124,10 @@ export class IgxBannerComponent implements IToggleView {
* ```typescript
* import { slideInLeft, slideOutRight } from 'igniteui-angular';
* ...
- * banner.animationSettings: AnimationSettings = { openAnimation: slideInLeft, closeAnimation: slideOutRight };
+ * banner.animationSettings: ToggleAnimationSettings = { openAnimation: slideInLeft, closeAnimation: slideOutRight };
* ```
*/
- public set animationSettings(settings: AnimationSettings) {
+ public set animationSettings(settings: ToggleAnimationSettings) {
this._animationSettings = settings;
}
/**
@@ -166,7 +166,7 @@ export class IgxBannerComponent implements IToggleView {
private _bannerActionTemplate: IgxBannerActionsDirective;
private _bannerEvent: BannerEventArgs;
- private _animationSettings: AnimationSettings;
+ private _animationSettings: ToggleAnimationSettings;
constructor(public elementRef: ElementRef) { }
diff --git a/projects/igniteui-angular/src/lib/core/i18n/resources.ts b/projects/igniteui-angular/src/lib/core/i18n/resources.ts
index c5cdcb7617d..ccea0ebf29c 100644
--- a/projects/igniteui-angular/src/lib/core/i18n/resources.ts
+++ b/projects/igniteui-angular/src/lib/core/i18n/resources.ts
@@ -8,10 +8,11 @@ import { IChipResourceStrings, ChipResourceStringsEN } from './chip-resources';
import { IListResourceStrings, ListResourceStringsEN } from './list-resources';
import { CalendarResourceStringsEN, ICalendarResourceStrings } from './calendar-resources';
import { IInputResourceStrings, InputResourceStringsEN } from './input-resources';
+import { ITreeResourceStrings, TreeResourceStringsEN } from './tree-resources';
export interface IResourceStrings extends IGridResourceStrings, ITimePickerResourceStrings, ICalendarResourceStrings,
ICarouselResourceStrings, IChipResourceStrings, IInputResourceStrings, IDateRangePickerResourceStrings, IListResourceStrings,
- IPaginatorResourceStrings { }
+ IPaginatorResourceStrings, ITreeResourceStrings { }
/**
* @hidden
@@ -26,6 +27,7 @@ export const CurrentResourceStrings = {
CarouselResStrings: cloneValue(CarouselResourceStringsEN),
ListResStrings: cloneValue(ListResourceStringsEN),
InputResStrings: cloneValue(InputResourceStringsEN),
+ TreeResStrings: cloneValue(TreeResourceStringsEN),
};
const updateResourceStrings = (currentStrings: IResourceStrings, newStrings: IResourceStrings) => {
diff --git a/projects/igniteui-angular/src/lib/core/i18n/tree-resources.ts b/projects/igniteui-angular/src/lib/core/i18n/tree-resources.ts
new file mode 100644
index 00000000000..2ac191e8f3b
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/core/i18n/tree-resources.ts
@@ -0,0 +1,9 @@
+export interface ITreeResourceStrings {
+ igx_expand?: string;
+ igx_collapse?: string;
+}
+
+export const TreeResourceStringsEN: ITreeResourceStrings = {
+ igx_expand: 'Expand',
+ igx_collapse: 'Collapse',
+};
diff --git a/projects/igniteui-angular/src/lib/core/styles/components/tree/_tree-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/tree/_tree-component.scss
new file mode 100644
index 00000000000..747f82498ce
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/core/styles/components/tree/_tree-component.scss
@@ -0,0 +1,86 @@
+////
+/// @group components
+/// @author Marin Popov
+/// @requires {mixin} bem-block
+/// @requires {mixin} bem-elem
+/// @requires {mixin} bem-mod
+////
+
+@include b(igx-tree) {
+ @extend %tree-display !optional;
+}
+
+@include b(igx-tree-node) {
+ $this: bem--selector-to-string(&);
+ @include register-component(str-slice($this, 2, -1));
+
+ @extend %tree-node !optional;
+
+ @include e(wrapper) {
+ @extend %node-wrapper !optional;
+
+ &:hover {
+ &::after {
+ @extend %indigo-opacity !optional;
+ }
+ }
+ }
+
+ @include e(wrapper, $m: cosy) {
+ @extend %node-wrapper--cosy !optional;
+ }
+
+ @include e(wrapper, $m: compact) {
+ @extend %node-wrapper--compact !optional;
+ }
+
+ // STATES START
+ @include e(wrapper, $m: selected) {
+ @extend %node-wrapper--selected !optional;
+ }
+
+ @include e(wrapper, $m: active) {
+ @extend %node-wrapper--active !optional;
+ }
+
+ @include e(wrapper, $mods: (active, selected)) {
+ @extend %node-wrapper--active-selected !optional;
+ }
+
+ @include e(wrapper, $m: focused) {
+ @extend %node-wrapper--focused !optional;
+ }
+
+ @include e(wrapper, $m: disabled) {
+ @extend %node-wrapper--disabled !optional;
+ }
+ // STATES END
+
+ @include e(content) {
+ @extend %node-content !optional;
+ }
+
+ @include e(spacer) {
+ @extend %node-spacer !optional;
+ }
+
+ @include e(toggle-button) {
+ @extend %node-toggle-button !optional;
+ }
+
+ @include e(toggle-button, $m: hidden) {
+ @extend %node-toggle-button--hidden !optional;
+ }
+
+ @include e(drop-indicator) {
+ @extend %node-drop-indicator !optional;
+ }
+
+ @include e(select) {
+ @extend %node-select !optional;
+ }
+
+ @include e(group) {
+ @extend %node-group !optional;
+ }
+}
diff --git a/projects/igniteui-angular/src/lib/core/styles/components/tree/_tree-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/tree/_tree-theme.scss
new file mode 100644
index 00000000000..f99efad7981
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/core/styles/components/tree/_tree-theme.scss
@@ -0,0 +1,343 @@
+////
+/// @group themes
+/// @access public
+/// @author Marin Popov
+////
+
+/// Returns a map containing all style properties related to the theming of the tree component.
+/// @param {Map} $palette [null] - The palette used as basis for styling the component.
+/// @param {Map} $schema [$light-schema] - The schema used as basis for styling the component.
+/// @param {Color} background [null] - The background color used for the tree node.
+/// @param {Color} foreground [null] - The color used for the tree node content.
+/// @param {Color} background-selected [null] - The background color used for the selected tree node.
+/// @param {Color} foreground-selected [null] - The color used for the content of the selected tree node.
+/// @param {Color} background-active [null] - The background color used for the active tree node.
+/// @param {Color} foreground-active [null] - The color used for the content of the active tree node.
+/// @param {Color} background-active-selected [null] - The background color used for the active selected tree node.
+/// @param {Color} foreground-active-selected [null] - The color used for the content of the active selected tree node.
+/// @param {Color} background-disabled [null] - The background color used for the tree node in disabled state.
+/// @param {Color} foreground-disabled [null] - The color used for the content of the disabled tree node.
+/// @param {Color} drop-area-color [null] - The background color used for the tree node drop aria.
+/// @param {Color} border-color [null] - The outline shadow color used for tree node in focus state.
+/// @requires $light-schema
+/// @requires apply-palette
+/// @requires extend
+/// @requires text-contrast
+/// @example scss Change the tree background
+/// $my-tree-theme: igx-tree-theme($background: magenta);
+/// // Pass the theme to the igx-tree component mixin
+/// @include igx-tree($my-tree-theme);
+@function igx-tree-theme(
+ $palette: null,
+ $schema: $light-schema,
+ $background: null,
+ $background-selected: null,
+ $background-active: null,
+ $background-active-selected: null,
+ $background-disabled: null,
+ $foreground: null,
+ $foreground-selected: null,
+ $foreground-active: null,
+ $foreground-active-selected: null,
+ $foreground-disabled: null,
+ $drop-area-color: null,
+ $border-color: null,
+) {
+ $name: 'igx-tree';
+ $tree-schema: ();
+
+ @if map-has-key($schema, $name) {
+ $tree-schema: map-get($schema, $name);
+ } @else {
+ $tree-schema: $schema;
+ }
+
+ $theme: apply-palette($tree-schema, $palette);
+
+ @if not($foreground) and $background {
+ $foreground: text-contrast($background);
+ }
+
+ @if not($foreground-selected) and $background-selected {
+ $foreground-selected: text-contrast($background-selected);
+ }
+
+ @if not($foreground-active) and $background-active {
+ $foreground-active: text-contrast($background-active);
+ }
+
+ @if not($foreground-active-selected) and $background-active-selected {
+ $foreground-active-selected: text-contrast($background-active-selected);
+ }
+
+ @return extend($theme, (
+ name: $name,
+ palette: $default-palette,
+ background: $background,
+ foreground: $foreground,
+ background-selected: $background-selected,
+ foreground-selected: $foreground-selected,
+ background-active: $background-active,
+ foreground-active: $foreground-active,
+ background-active-selected: $background-active-selected,
+ foreground-active-selected: $foreground-active-selected,
+ background-disabled: $background-disabled,
+ foreground-disabled: $foreground-disabled,
+ drop-area-color: $drop-area-color,
+ border-color: $border-color,
+ ));
+}
+
+/// @param {Map} $theme - The theme used to style the component.
+/// @requires {mixin} igx-css-vars
+/// @requires --var
+@mixin igx-tree($theme) {
+ @include igx-css-vars($theme);
+
+ $left: if-ltr(left, right);
+ $right: if-ltr(right, left);
+
+ $variant: map-get($theme, variant);
+ $indigo-theme: $variant == 'indigo-design';
+
+ $node-indent: (
+ comfortable: rem(24px),
+ cosy: rem(16px),
+ compact: rem(12px)
+ );
+
+ $node-height: (
+ comfortable: rem(50px),
+ cosy: rem(40px),
+ compact: rem(32px)
+ );
+
+ $icon-size: rem(24px);
+ $icon-space: rem(8px);
+
+ $drop-indicator-width: (
+ comfortable: calc(100% - ((#{map-get($node-indent, 'comfortable')} * 2) + (#{$icon-size} + #{$icon-space}))),
+ cosy: calc(100% - ((#{map-get($node-indent, 'cosy')} * 2) + (#{$icon-size} + #{$icon-space}))),
+ compact: calc(100% - ((#{map-get($node-indent, 'compact')} * 2) + (#{$icon-size} + #{$icon-space})))
+ );
+
+ %tree-display {
+ display: block;
+ z-index: 0;
+ }
+
+ %tree-node,
+ %node-wrapper,
+ %node-toggle-button,
+ %node-content,
+ %node-select {
+ display: flex;
+ }
+
+ %tree-node {
+ flex-direction: column;
+ }
+
+ %node-wrapper,
+ %node-toggle-button,
+ %node-select {
+ align-items: center;
+ }
+
+ %node-toggle-button,
+ %node-select {
+ margin-#{$right}: $icon-space;
+ }
+
+ %node-content,
+ %node-toggle-button,
+ %node-select {
+ z-index: 1;
+ }
+
+ %node-toggle-button--hidden {
+ visibility: hidden;
+ }
+
+ %node-wrapper {
+ min-height: map-get($node-height, 'comfortable');
+ padding: 0 map-get($node-indent, 'comfortable');
+ position: relative;
+ background: --var($theme, 'background');
+ color: --var($theme, 'foreground');
+
+ // We need this here, since we count on it to calculate the width of the tree drop indicator
+ igx-icon {
+ width: $icon-size;
+ height: $icon-size;
+ font-size: $icon-size;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: transparent;
+ z-index: 0;
+ }
+
+ &:hover {
+ &::after {
+ background: --var($theme, 'hover-color');
+ }
+ }
+
+ &:focus {
+ outline-width: 0;
+ }
+
+ igx-circular-bar {
+ width: $icon-size;
+ height: $icon-size;
+ }
+
+ &--cosy {
+ min-height: map-get($node-height, 'cosy');
+ padding: 0 map-get($node-indent, 'cosy');
+
+ %node-spacer {
+ width: map-get($node-indent, 'cosy')
+ }
+
+ %node-drop-indicator {
+ #{$right}: map-get($node-indent, 'cosy');
+ width: map-get($drop-indicator-width, 'cosy');
+ }
+
+ igx-circular-bar {
+ width: calc(#{$icon-size} - 4px);
+ height: calc(#{$icon-size} - 4px);
+ }
+ }
+
+ &--compact {
+ min-height: map-get($node-height, 'compact');
+ padding: 0 map-get($node-indent, 'compact');
+
+ %node-spacer {
+ width: map-get($node-indent, 'compact')
+ }
+
+ %node-drop-indicator {
+ #{$right}: map-get($node-indent, 'compact');
+ width: map-get($drop-indicator-width, 'compact');
+ }
+
+ igx-circular-bar {
+ width: calc(#{$icon-size} - 6px);
+ height: calc(#{$icon-size} - 6px);
+ }
+ }
+ }
+
+ %node-wrapper--selected {
+ background: --var($theme, 'background-selected');
+ color: --var($theme, 'foreground-selected');
+ }
+
+ %node-wrapper--active {
+ background: --var($theme, 'background-active');
+ color: --var($theme, 'foreground-active');
+ }
+
+ %indigo-opacity {
+ $bg-active: map-get($theme, 'background-active');
+
+ @if ($indigo-theme) {
+ @if type-of($bg-active) == 'color' and lightness($bg-active) < 50 {
+ opacity: .8;
+ } @else {
+ opacity: .3;
+ }
+ }
+ }
+
+ %node-wrapper--active-selected {
+ background: --var($theme, 'background-active-selected');
+ color: --var($theme, 'foreground-active-selected');
+ }
+
+ %node-wrapper--focused {
+ box-shadow: inset 0 0 0 1px --var($theme, 'border-color');
+ }
+
+ %node-wrapper--disabled {
+ background: --var($theme, 'background-disabled') !important;
+ color: --var($theme, 'foreground-disabled') !important;
+
+ box-shadow: none !important;
+
+ pointer-events: none;
+
+ &::after {
+ display: none;
+ }
+
+ %node-toggle-button {
+ color: --var($theme, 'foreground-disabled') !important;
+ }
+ }
+
+ %node-spacer {
+ display: inline-block;
+ width: map-get($node-indent, 'comfortable');
+ }
+
+ %node-content {
+ display: block;
+ align-items: center;
+ flex: 1;
+ @include ellipsis();
+ }
+
+ %node-toggle-button {
+ justify-content: center;
+ cursor: pointer;
+ user-select: none;
+ min-width: $icon-size
+ }
+
+ %node-drop-indicator {
+ display: flex;
+ visibility: hidden;
+ position: absolute;
+ #{$right}: map-get($node-indent, 'comfortable');
+ bottom: 0;
+ width: map-get($drop-indicator-width, 'comfortable');
+
+ > div {
+ flex: 1;
+ height: rem(1px);
+ background: --var($theme, 'drop-area-color');
+ }
+ }
+
+ %node-group {
+ overflow: hidden;
+ }
+}
+
+/// Adds typography styles for the igx-tree component.
+/// Uses the 'subtitle-1' category from the typographic scale.
+/// @group typography
+/// @param {Map} $type-scale - A typographic scale as produced by igx-type-scale.
+/// @param {Map} $categories [(label: 'subtitle-1')] - The categories from the typographic scale used for type styles.
+/// @requires {mixin} igx-type-style
+@mixin igx-tree-typography(
+ $type-scale,
+ $categories: (label: 'body-2')
+) {
+ $text: map-get($categories, 'label');
+
+ %node-content {
+ @include igx-type-style($type-scale, $text)
+ }
+}
diff --git a/projects/igniteui-angular/src/lib/core/styles/themes/_core.scss b/projects/igniteui-angular/src/lib/core/styles/themes/_core.scss
index c9555320c97..6ee4b013c61 100644
--- a/projects/igniteui-angular/src/lib/core/styles/themes/_core.scss
+++ b/projects/igniteui-angular/src/lib/core/styles/themes/_core.scss
@@ -74,6 +74,7 @@
@import '../components/toast/toast-component';
@import '../components/tooltip/tooltip-component';
@import '../components/time-picker/time-picker-component';
+@import '../components/tree/tree-component';
@import '../components/watermark/watermark-component';
@import '../print/index';
diff --git a/projects/igniteui-angular/src/lib/core/styles/themes/_index.scss b/projects/igniteui-angular/src/lib/core/styles/themes/_index.scss
index 786a03537c7..e9f15f97bba 100644
--- a/projects/igniteui-angular/src/lib/core/styles/themes/_index.scss
+++ b/projects/igniteui-angular/src/lib/core/styles/themes/_index.scss
@@ -62,6 +62,7 @@
@import '../components/input/input-group-theme';
@import '../components/icon/icon-theme';
@import '../components/time-picker/time-picker-theme';
+@import '../components/tree/tree-theme';
@import '../components/watermark/watermark-theme';
/// Generates an Ignite UI for Angular global theme.
@@ -475,6 +476,13 @@
));
}
+ @if not(index($exclude, 'igx-tree')) {
+ @include igx-tree(igx-tree-theme(
+ $palette: $palette,
+ $schema: $schema,
+ ));
+ }
+
@if not(index($exclude, 'igx-watermark')) {
@include igx-watermark(igx-watermark-theme(
$schema: $schema,
@@ -733,4 +741,3 @@
$elevation: $elevation,
);
}
-
diff --git a/projects/igniteui-angular/src/lib/core/styles/themes/schemas/dark/_index.scss b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/dark/_index.scss
index e84c812eb61..aea995689c5 100644
--- a/projects/igniteui-angular/src/lib/core/styles/themes/schemas/dark/_index.scss
+++ b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/dark/_index.scss
@@ -59,6 +59,7 @@
@import './time-picker';
@import './toast';
@import './tooltip';
+@import './tree';
@import './watermark';
/// Used to create Material Design themes.
@@ -121,6 +122,7 @@
/// @property {Map} igx-time-picker [$_dark-time-picker]
/// @property {Map} igx-toast [$_dark-toast]
/// @property {Map} igx-tooltip [$_dark-tooltip]
+/// @property {Map} igx-tree [$_dark-tree]
$dark-schema: (
igx-avatar: $_dark-avatar,
igx-action-strip: $_dark-action-strip,
@@ -179,6 +181,7 @@ $dark-schema: (
igx-time-picker: $_dark-time-picker,
igx-toast: $_dark-toast,
igx-tooltip: $_dark-tooltip,
+ igx-tree: $_dark-tree,
igx-watermark: $_dark-watermark,
);
@@ -247,6 +250,7 @@ $dark-material-schema: $dark-schema;
/// @property {Map} igx-time-picker [$_dark-fluent-time-picker],
/// @property {Map} igx-toast [$_dark-fluent-toast],
/// @property {Map} igx-tooltip [$_dark-fluent-tooltip]
+/// @property {Map} igx-tree [$_dark-fluent-tree]
$dark-fluent-schema: (
igx-avatar: $_dark-fluent-avatar,
igx-action-strip: $_dark-fluent-action-strip,
@@ -305,6 +309,7 @@ $dark-fluent-schema: (
igx-time-picker: $_dark-fluent-time-picker,
igx-toast: $_dark-fluent-toast,
igx-tooltip: $_dark-fluent-tooltip,
+ igx-tree: $_dark-fluent-tree,
igx-watermark: $_dark-fluent-watermark,
);
@@ -368,6 +373,7 @@ $dark-fluent-schema: (
/// @property {Map} igx-time-picker [$_dark-bootstrap-time-picker],
/// @property {Map} igx-toast [$_dark-bootstrap-toast],
/// @property {Map} igx-tooltip [$_dark-bootstrap-tooltip]
+/// @property {Map} igx-tree [$_dark-bootstrap-tree]
$dark-bootstrap-schema: (
igx-avatar: $_dark-bootstrap-avatar,
igx-action-strip: $_dark-bootstrap-action-strip,
@@ -426,6 +432,7 @@ $dark-bootstrap-schema: (
igx-time-picker: $_dark-bootstrap-time-picker,
igx-toast: $_dark-bootstrap-toast,
igx-tooltip: $_dark-bootstrap-tooltip,
+ igx-tree: $_dark-bootstrap-tree,
igx-watermark: $_dark-bootstrap-watermark,
);
@@ -489,6 +496,7 @@ $dark-bootstrap-schema: (
/// @property {Map} igx-time-picker [$_dark-indigo-time-picker]
/// @property {Map} igx-toast [$_dark-indigo-toast]
/// @property {Map} igx-tooltip [$_dark-indigo-tooltip]
+/// @property {Map} igx-tree [$_dark-indigo-tree]
$dark-indigo-schema: (
igx-avatar: $_dark-indigo-avatar,
igx-action-strip: $_dark-indigo-action-strip,
@@ -547,6 +555,7 @@ $dark-indigo-schema: (
igx-time-picker: $_dark-indigo-time-picker,
igx-toast: $_dark-indigo-toast,
igx-tooltip: $_dark-indigo-tooltip,
+ igx-tree: $_dark-indigo-tree,
igx-watermark: $_dark-indigo-watermark,
);
diff --git a/projects/igniteui-angular/src/lib/core/styles/themes/schemas/dark/_tree.scss b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/dark/_tree.scss
new file mode 100644
index 00000000000..ce3d052cf89
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/dark/_tree.scss
@@ -0,0 +1,76 @@
+@import '../light/tree';
+////
+/// @group schemas
+/// @access public
+/// @author Marin Popov
+////
+
+/// Generates a base dark tree schema.
+/// @type {Map}
+/// @prop {Map} background-active [igx-color: ('grays', 300)] - The background color used for the active tree node.
+/// @prop {Map} foreground-active [igx-color: ('grays', 300)] - The color used for the content in active state of the tree node.
+$_base-dark-tree: (
+ background-active: (
+ igx-color: ('grays', 300),
+ ),
+
+ foreground-active: (
+ igx-color: ('grays', 900),
+ ),
+
+ hover-color: (
+ igx-color: ('grays', 300)
+ )
+);
+
+/// Generates a dark tree schema.
+/// @type {Map}
+/// @requires {function} extend
+/// @requires $_light-tree
+/// @requires $_base-dark-tree
+$_dark-tree: extend($_light-tree, $_base-dark-tree);
+
+/// Generates a dark fluent tree schema.
+/// @type {Map}
+/// @prop {Map} background-selected [igx-color: ('grays', 400)] - The background color used for the selected tree node.
+/// @prop {Map} foreground-selected [igx-color: ('grays', 900)] - The color used for the content of the selected tree node.
+/// @prop {Map} background-active-selected [igx-color: ('grays', 200)] - The background color used for the active selected tree node.
+/// @prop {Map} foreground-active-selected [igx-color: ('grays', 900)] - The color used for the content of the active selected tree node.
+/// @requires {function} extend
+/// @requires $_fluent-tree
+/// @requires $_base-dark-tree
+$_dark-fluent-tree: extend(
+ $_fluent-tree,
+ $_base-dark-tree,
+ (
+ backgroubd-selected: (
+ igx-color: ('grays', 400)
+ ),
+
+ foreground-selected: (
+ igx-color: ('grays', 900)
+ ),
+
+ background-active-selected: (
+ igx-color: ('grays', 200)
+ ),
+
+ foreground-active-selected: (
+ igx-color: ('grays', 900)
+ ),
+ )
+);
+
+/// Generates a dark bootstrap tree schema.
+/// @type {Map}
+/// @requires {function} extend
+/// @requires $_bootstrap-tree
+/// @requires $_base-dark-tree
+$_dark-bootstrap-tree: extend($_bootstrap-tree, $_base-dark-tree);
+
+/// Generates a dark indigo tree schema.
+/// @type {Map}
+/// @requires {function} extend
+/// @requires $_indigo-tree
+/// @requires $_base-dark-tree
+$_dark-indigo-tree: extend($_indigo-tree, $_base-dark-tree);
diff --git a/projects/igniteui-angular/src/lib/core/styles/themes/schemas/light/_index.scss b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/light/_index.scss
index 68898178fc6..b776bc62c36 100644
--- a/projects/igniteui-angular/src/lib/core/styles/themes/schemas/light/_index.scss
+++ b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/light/_index.scss
@@ -59,6 +59,7 @@
@import './time-picker';
@import './toast';
@import './tooltip';
+@import './tree';
@import './watermark';
/// The default schema. Used to create Material Design themes.
@@ -121,6 +122,7 @@
/// @property {Map} igx-time-picker [$_light-time-picker]
/// @property {Map} igx-toast [$_light-toast]
/// @property {Map} igx-tooltip [$_light-tooltip]
+/// @property {Map} igx-tree [$_light-tree]
$light-schema: (
igx-avatar: $_light-avatar,
igx-action-strip: $_light-action-strip,
@@ -179,6 +181,7 @@ $light-schema: (
igx-time-picker: $_light-time-picker,
igx-toast: $_light-toast,
igx-tooltip: $_light-tooltip,
+ igx-tree: $_light-tree,
igx-watermark: $_light-watermark
);
@@ -248,6 +251,7 @@ $light-fluent-schema: (
igx-time-picker: $_fluent-time-picker,
igx-toast: $_fluent-toast,
igx-tooltip: $_fluent-tooltip,
+ igx-tree: $_fluent-tree,
igx-watermark: $_fluent-watermark
);
@@ -312,6 +316,7 @@ $light-bootstrap-schema: (
igx-time-picker: $_bootstrap-time-picker,
igx-toast: $_bootstrap-toast,
igx-tooltip: $_bootstrap-tooltip,
+ igx-tree: $_bootstrap-tree,
igx-watermark: $_bootstrap-watermark
);
@@ -376,6 +381,7 @@ $light-indigo-schema: (
igx-time-picker: $_indigo-time-picker,
igx-toast: $_indigo-toast,
igx-tooltip: $_indigo-tooltip,
+ igx-tree: $_indigo-tree,
igx-watermark: $_indigo-watermark
);
diff --git a/projects/igniteui-angular/src/lib/core/styles/themes/schemas/light/_tree.scss b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/light/_tree.scss
new file mode 100644
index 00000000000..54065742d0d
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/core/styles/themes/schemas/light/_tree.scss
@@ -0,0 +1,237 @@
+////
+/// @group schemas
+/// @access public
+/// @author Marin Popov
+////
+
+/// Generates a light tree schema.
+/// @type {Map}
+/// @prop {Map} background [ igx-color: ('surface')] - The background color used for the tree node.
+/// @prop {Map} background-selected [igx-color: ('secondary', 200)] - The background color used for the selected tree node.
+/// @prop {Map} background-active [igx-color: ('grays', 200)] - The background color used for the active tree node.
+/// @prop {Map} background-active-selected [igx-color: ('grays', 200)] - The background color used for the active selected tree node.
+/// @prop {Map} background-disabled [igx-color: ('secondary', 300)] - The background color used for the tree node in disabled state.
+/// @prop {Map} foreground [igx-contrast-color: ('surface')] - The color used for the tree node content.
+/// @prop {Map} foreground-selected [igx-contrast-color: ('secondary', 200)] - The color used for the content of the selected tree node.
+/// @prop {Map} foreground-active [igx-contrast-color: ('grays', 200)] - The color used for the content in active state of the tree node.
+/// @prop {Map} foreground-active-selected [igx-contrast-color: ('grays', 200)] - The color used for the content of the active selected tree node.
+/// @prop {Map} foreground-disabled [igx-contrast-color: ('secondary', 300)] - The color used for the content of the disabled tree node.
+/// @prop {Map} drop-area-color [igx-color: ('secondary'] - The background color used for the tree node drop aria.
+/// @prop {Map} border-color [igx-color: ('secondary')] - The outline shadow color used for tree node in focus state.
+/// @requires {function} extend
+/// @see $default-palette
+$_light-tree: extend(
+ (
+ variant: 'material',
+
+ background: (
+ igx-color: ('surface')
+ ),
+
+ foreground: (
+ igx-contrast-color: ('surface')
+ ),
+
+ background-selected: (
+ igx-color: ('secondary', 200)
+ ),
+
+ foreground-selected: (
+ igx-contrast-color: ('secondary', 200)
+ ),
+
+ background-active: (
+ igx-color: ('grays', 200),
+ ),
+
+ foreground-active: (
+ igx-contrast-color: ('grays', 200),
+ ),
+
+ background-active-selected: (
+ igx-color: ('secondary', 300)
+ ),
+
+ foreground-active-selected: (
+ igx-contrast-color: ('secondary', 300),
+ ),
+
+ border-color: (
+ igx-color: ('secondary')
+ ),
+
+ // Same for all themes
+
+ background-disabled: (
+ igx-color: ('surface')
+ ),
+
+ foreground-disabled: (
+ igx-color: ('grays', 500)
+ ),
+
+ drop-area-color: (
+ igx-color: ('secondary')
+ ),
+
+ hover-color: (
+ igx-color: ('grays', 200)
+ )
+ )
+);
+
+/// Generates a light fluent tree schema.
+/// @type {Map}
+/// @prop {Map} background [ igx-color: ('surface')] - The background color used for the tree node.
+/// @prop {Map} foreground [igx-contrast-color: ('surface')] - The color used for the tree node content.
+/// @prop {Map} background-selected [igx-color: ('grays', 100)] - The background color used for the selected tree node.
+/// @prop {Map} foreground-selected [igx-contrast-color: ('grays', 100)] - The color used for the content of the selected tree node.
+/// @prop {Map} background-active [igx-color: ('surface')] - The background color used for the active tree node.
+/// @prop {Map} foreground-active [igx-contrast-color: ('surface')] - The color used for the content in active state of the tree node.
+/// @prop {Map} background-active-selected [igx-color: ('grays', 200)] - The background color used for the active selected tree node.
+/// @prop {Map} foreground-active-selected [igx-contrast-color: ('grays', 200)] - The color used for the content of the active selected tree node.
+/// @prop {Map} border-color [igx-color: ('grays', 800)] - The outline shadow color used for tree node in focus state.
+/// @requires {function} extend
+/// @requires {Map} $_light-tree
+$_fluent-tree: extend(
+ $_light-tree,
+ (
+ variant: 'fluent',
+
+ background: (
+ igx-color: ('surface')
+ ),
+
+ foreground: (
+ igx-contrast-color: ('surface')
+ ),
+
+ background-selected: (
+ igx-color: ('grays', 100)
+ ),
+
+ foreground-selected: (
+ igx-contrast-color: ('grays', 100)
+ ),
+
+ background-active: (
+ igx-color: ('surface')
+ ),
+
+ foreground-active: (
+ igx-contrast-color: ('surface'),
+ ),
+
+ background-active-selected: (
+ igx-color: ('grays', 200)
+ ),
+
+ foreground-active-selected: (
+ igx-contrast-color: ('grays', 200),
+ ),
+
+ border-color: (
+ igx-color: ('grays', 800)
+ ),
+ )
+);
+
+/// Generates a bootstrap tree schema.
+/// @type {Map}
+/// @prop {Map} background-active [igx-color: ('surface')] - The background color used for the active tree node.
+/// @prop {Map} foreground-active [igx-contrast-color: ('primary')] - The color used for the content in active state of the tree node.
+/// @prop {Map} background-selected [igx-color: ('primary')] - The background color used for the selected tree node.
+/// @prop {Map} foreground-selected [igx-contrast-color: ('primary', 600)] - The color used for the content of the selected tree node.
+/// @prop {Map} background-active-selected [igx-color: ('primary', 600)] - The background color used for the active selected tree node.
+/// @prop {Map} foreground-active-selected [igx-contrast-color: ('primary', 600)] - The color used for the content of the active selected tree node.
+/// @prop {Map} border-color [igx-color: ('primary', 200)] - The outline shadow color used for tree node in focus state.
+/// @requires {function} extend
+/// @requires $_light-tree
+$_bootstrap-tree: extend(
+ $_light-tree,
+ (
+ variant: 'bootstrap',
+
+ background-active: (
+ igx-color: ('surface'),
+ ),
+
+ foreground-active: (
+ igx-color: ('primary'),
+ ),
+
+ background-selected: (
+ igx-color: ('primary')
+ ),
+
+ foreground-selected: (
+ igx-contrast-color: ('primary', 600)
+ ),
+
+ background-active-selected: (
+ igx-color: ('primary', 600)
+ ),
+
+ foreground-active-selected: (
+ igx-contrast-color: ('primary', 600),
+ ),
+
+ border-color: (
+ igx-color: ('primary' 200)
+ ),
+
+ hover-color: (
+ igx-color: ('grays', 100)
+ )
+ )
+);
+
+/// Generates an indigo tree schema.
+/// @type {Map}
+/// @prop {Map} background-active [igx-color: ('surface')] - The background color used for the active tree node.
+/// @prop {Map} foreground-active [igx-contrast-color: ('primary')] - The color used for the content in active state of the tree node.
+/// @prop {Map} background-selected [igx-color: ('primary')] - The background color used for the selected tree node.
+/// @prop {Map} foreground-selected [igx-contrast-color: ('primary')] - The color used for the content of the selected tree node.
+/// @prop {Map} background-active-selected [igx-color: ('primary', 400)] - The background color used for the active selected tree node.
+/// @prop {Map} foreground-active-selected [igx-contrast-color: ('primary', 400)] - The color used for the content of the active selected tree node.
+/// @prop {Map} border-color [igx-color: ('primary', 300)] - The outline shadow color used for tree node in focus state.
+/// @requires {function} extend
+/// @requires {Map} $_light-tree
+$_indigo-tree: extend(
+ $_light-tree,
+ (
+ variant: 'indigo-design',
+
+ background-active: (
+ igx-color: ('surface'),
+ ),
+
+ foreground-active: (
+ igx-color: ('primary'),
+ ),
+
+ background-selected: (
+ igx-color: ('primary')
+ ),
+
+ foreground-selected: (
+ igx-contrast-color: ('primary')
+ ),
+
+ background-active-selected: (
+ igx-color: ('primary', 400)
+ ),
+
+ foreground-active-selected: (
+ igx-contrast-color: ('primary', 400),
+ ),
+
+ border-color: (
+ igx-color: ('primary', 300)
+ ),
+
+ hover-color: (
+ igx-color: ('primary', 100)
+ )
+ )
+);
diff --git a/projects/igniteui-angular/src/lib/core/styles/typography/_typography.scss b/projects/igniteui-angular/src/lib/core/styles/typography/_typography.scss
index 5e15b27172d..c066145e58b 100644
--- a/projects/igniteui-angular/src/lib/core/styles/typography/_typography.scss
+++ b/projects/igniteui-angular/src/lib/core/styles/typography/_typography.scss
@@ -76,6 +76,7 @@
@include igx-time-picker-typography($type-scale);
@include igx-toast-typography($type-scale);
@include igx-tooltip-typography($type-scale);
+ @include igx-tree-typography($type-scale);
// Add theme type-scale specific quirks
@if ($_variant == 'material' or $_variant == 'fluent') {
diff --git a/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel.component.ts b/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel.component.ts
index 2bdfa5b86f4..14531e43ff2 100644
--- a/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel.component.ts
+++ b/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel.component.ts
@@ -8,24 +8,20 @@ import {
ContentChild,
AfterContentInit
} from '@angular/core';
-import { AnimationBuilder, AnimationReferenceMetadata, useAnimation } from '@angular/animations';
-import { growVerOut, growVerIn } from '../animations/main';
+import { AnimationBuilder } from '@angular/animations';
import { IgxExpansionPanelBodyComponent } from './expansion-panel-body.component';
import { IgxExpansionPanelHeaderComponent } from './expansion-panel-header.component';
import { IGX_EXPANSION_PANEL_COMPONENT, IgxExpansionPanelBase, IExpansionPanelEventArgs } from './expansion-panel.common';
+import { ToggleAnimationPlayer, ToggleAnimationSettings } from './toggle-animation-component';
let NEXT_ID = 0;
-export interface AnimationSettings {
- openAnimation: AnimationReferenceMetadata;
- closeAnimation: AnimationReferenceMetadata;
-}
@Component({
selector: 'igx-expansion-panel',
templateUrl: 'expansion-panel.component.html',
providers: [{ provide: IGX_EXPANSION_PANEL_COMPONENT, useExisting: IgxExpansionPanelComponent }]
})
-export class IgxExpansionPanelComponent implements IgxExpansionPanelBase, AfterContentInit {
+export class IgxExpansionPanelComponent extends ToggleAnimationPlayer implements IgxExpansionPanelBase, AfterContentInit {
/**
* Sets/gets the animation settings of the expansion panel component
* Open and Close animation should be passed
@@ -58,10 +54,12 @@ export class IgxExpansionPanelComponent implements IgxExpansionPanelBase, AfterC
* ```
*/
@Input()
- public animationSettings: AnimationSettings = {
- openAnimation: growVerIn,
- closeAnimation: growVerOut
- };
+ public get animationSettings(): ToggleAnimationSettings {
+ return this._animationSettings;
+ }
+ public set animationSettings(value: ToggleAnimationSettings) {
+ this._animationSettings = value;
+ }
/**
* Sets/gets the `id` of the expansion panel component.
@@ -165,7 +163,9 @@ export class IgxExpansionPanelComponent implements IgxExpansionPanelBase, AfterC
private _collapsed = true;
- constructor(private cdr: ChangeDetectorRef, private builder: AnimationBuilder) { }
+ constructor(private cdr: ChangeDetectorRef, protected builder: AnimationBuilder) {
+ super(builder);
+ }
/** @hidden */
public ngAfterContentInit(): void {
@@ -193,6 +193,7 @@ export class IgxExpansionPanelComponent implements IgxExpansionPanelBase, AfterC
return;
}
this.playCloseAnimation(
+ this.body?.element,
() => {
this.onCollapsed.emit({ event: evt, panel: this, owner: this });
this.collapsed = true;
@@ -218,6 +219,7 @@ export class IgxExpansionPanelComponent implements IgxExpansionPanelBase, AfterC
this.collapsed = false;
this.cdr.detectChanges();
this.playOpenAnimation(
+ this.body?.element,
() => {
this.onExpanded.emit({ event: evt, panel: this, owner: this });
}
@@ -245,38 +247,8 @@ export class IgxExpansionPanelComponent implements IgxExpansionPanelBase, AfterC
public open(evt?: Event) {
this.expand(evt);
}
+
public close(evt?: Event) {
this.collapse(evt);
}
-
- private playOpenAnimation(cb: () => void) {
- if (!this.body) { // if not body element is passed, there is nothing to animate
- return;
- }
- const animation = useAnimation(this.animationSettings.openAnimation);
- const animationBuilder = this.builder.build(animation);
- const openAnimationPlayer = animationBuilder.create(this.body.element.nativeElement);
-
- openAnimationPlayer.onDone(() => {
- cb();
- openAnimationPlayer.reset();
- });
-
- openAnimationPlayer.play();
- }
-
- private playCloseAnimation(cb: () => void) {
- if (!this.body) { // if not body element is passed, there is nothing to animate
- return;
- }
- const animation = useAnimation(this.animationSettings.closeAnimation);
- const animationBuilder = this.builder.build(animation);
- const closeAnimationPlayer = animationBuilder.create(this.body.element.nativeElement);
- closeAnimationPlayer.onDone(() => {
- cb();
- closeAnimationPlayer.reset();
- });
-
- closeAnimationPlayer.play();
- }
}
diff --git a/projects/igniteui-angular/src/lib/expansion-panel/toggle-animation-component.spec.ts b/projects/igniteui-angular/src/lib/expansion-panel/toggle-animation-component.spec.ts
new file mode 100644
index 00000000000..7d49eb71a18
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/expansion-panel/toggle-animation-component.spec.ts
@@ -0,0 +1,44 @@
+import { AnimationBuilder } from '@angular/animations';
+import { TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { noop } from 'rxjs';
+import { configureTestSuite } from '../test-utils/configure-suite';
+import { ToggleAnimationPlayer ,ANIMATION_TYPE } from './toggle-animation-component';
+
+class MockTogglePlayer extends ToggleAnimationPlayer {
+ constructor(protected builder: AnimationBuilder) {
+ super(builder);
+ }
+}
+
+describe('Toggle animation component', () => {
+ configureTestSuite();
+ const mockBuilder = jasmine.createSpyObj('mockBuilder', ['build'], {});
+ beforeAll(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule
+ ]
+ }).compileComponents();
+ });
+ describe('Unit tests', () => {
+ it('Should initialize player with give settings', () => {
+ const player = new MockTogglePlayer(mockBuilder);
+ const startPlayerSpy = spyOn(player, 'startPlayer');
+ const mockEl = jasmine.createSpyObj('mockRef', ['focus'], {});
+ player.playOpenAnimation(mockEl);
+ expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.OPEN, mockEl, noop);
+ player.playCloseAnimation(mockEl);
+ expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.CLOSE, mockEl, noop);
+ const mockCB = () => {};
+ player.playOpenAnimation(mockEl, mockCB);
+ expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.OPEN, mockEl, mockCB);
+ player.playCloseAnimation(mockEl, mockCB);
+ expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.CLOSE, mockEl, mockCB);
+ player.playOpenAnimation(null, mockCB);
+ expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.OPEN, null, mockCB);
+ player.playCloseAnimation(null, mockCB);
+ expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.CLOSE, null, mockCB);
+ });
+ });
+});
diff --git a/projects/igniteui-angular/src/lib/expansion-panel/toggle-animation-component.ts b/projects/igniteui-angular/src/lib/expansion-panel/toggle-animation-component.ts
new file mode 100644
index 00000000000..f0878ab3307
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/expansion-panel/toggle-animation-component.ts
@@ -0,0 +1,190 @@
+import { AnimationBuilder, AnimationPlayer, AnimationReferenceMetadata, useAnimation } from '@angular/animations';
+import { Directive, ElementRef, EventEmitter, OnDestroy } from '@angular/core';
+import { noop, Subject } from 'rxjs';
+import { growVerIn, growVerOut } from '../animations/grow';
+
+/**@hidden @internal */
+export interface ToggleAnimationSettings {
+ openAnimation: AnimationReferenceMetadata;
+ closeAnimation: AnimationReferenceMetadata;
+}
+
+export interface ToggleAnimationOwner {
+ animationSettings: ToggleAnimationSettings;
+ openAnimationStart: EventEmitter;
+ openAnimationDone: EventEmitter;
+ closeAnimationStart: EventEmitter;
+ closeAnimationDone: EventEmitter;
+ openAnimationPlayer: AnimationPlayer;
+ closeAnimationPlayer: AnimationPlayer;
+ playOpenAnimation(element: ElementRef, onDone: () => void): void;
+ playCloseAnimation(element: ElementRef, onDone: () => void): void;
+}
+
+/** @hidden @internal */
+export enum ANIMATION_TYPE {
+ OPEN = 'open',
+ CLOSE = 'close',
+}
+
+/**@hidden @internal */
+@Directive()
+// eslint-disable-next-line @angular-eslint/directive-class-suffix
+export abstract class ToggleAnimationPlayer implements ToggleAnimationOwner, OnDestroy {
+
+
+ /** @hidden @internal */
+ public openAnimationDone: EventEmitter = new EventEmitter();
+ /** @hidden @internal */
+ public closeAnimationDone: EventEmitter = new EventEmitter();
+ /** @hidden @internal */
+ public openAnimationStart: EventEmitter = new EventEmitter();
+ /** @hidden @internal */
+ public closeAnimationStart: EventEmitter = new EventEmitter();
+
+ public get animationSettings(): ToggleAnimationSettings {
+ return this._animationSettings;
+ }
+ public set animationSettings(value: ToggleAnimationSettings) {
+ this._animationSettings = value;
+ }
+
+ /** @hidden @internal */
+ public openAnimationPlayer: AnimationPlayer = null;
+
+ /** @hidden @internal */
+ public closeAnimationPlayer: AnimationPlayer = null;
+
+
+
+ protected destroy$: Subject = new Subject();
+ protected players: Map = new Map();
+ protected _animationSettings: ToggleAnimationSettings = {
+ openAnimation: growVerIn,
+ closeAnimation: growVerOut
+ };
+
+ private closeInterrupted = false;
+ private openInterrupted = false;
+
+ private _defaultClosedCallback = noop;
+ private _defaultOpenedCallback = noop;
+ private onClosedCallback: () => any = this._defaultClosedCallback;
+ private onOpenedCallback: () => any = this._defaultOpenedCallback;
+
+ constructor(protected builder: AnimationBuilder) {
+ }
+
+ /** @hidden @internal */
+ public playOpenAnimation(targetElement: ElementRef, onDone?: () => void): void {
+ this.startPlayer(ANIMATION_TYPE.OPEN, targetElement, onDone || this._defaultOpenedCallback);
+ }
+
+ /** @hidden @internal */
+ public playCloseAnimation(targetElement: ElementRef, onDone?: () => void): void {
+ this.startPlayer(ANIMATION_TYPE.CLOSE, targetElement, onDone || this._defaultClosedCallback);
+ }
+ public ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private startPlayer(type: ANIMATION_TYPE, targetElement: ElementRef, callback: () => void): void {
+ if (!targetElement) { // if no element is passed, there is nothing to animate
+ return;
+ }
+
+ let target = this.getPlayer(type);
+ if (!target) {
+ target = this.initializePlayer(type, targetElement, callback);
+ }
+
+ if (target.hasStarted()) {
+ return;
+ }
+
+ const targetEmitter = type === ANIMATION_TYPE.OPEN ? this.openAnimationStart : this.closeAnimationStart;
+ targetEmitter.emit();
+ target.play();
+ }
+
+ private initializePlayer(type: ANIMATION_TYPE, targetElement: ElementRef, callback: () => void): AnimationPlayer {
+ const oppositeType = type === ANIMATION_TYPE.OPEN ? ANIMATION_TYPE.CLOSE : ANIMATION_TYPE.OPEN;
+ const animationSettings = type === ANIMATION_TYPE.OPEN ?
+ this.animationSettings.openAnimation : this.animationSettings.closeAnimation;
+ const animation = useAnimation(animationSettings);
+ const animationBuilder = this.builder.build(animation);
+ const opposite = this.getPlayer(oppositeType);
+ let oppositePosition = 1;
+ if (opposite) {
+ if (opposite.hasStarted()) {
+ // .getPosition() still returns 0 sometimes, regardless of the fix for https://github.com/angular/angular/issues/18891;
+ oppositePosition = (opposite as any)._renderer.engine.players[0].getPosition();
+ }
+
+ this.cleanUpPlayer(oppositeType);
+ }
+ if (type === ANIMATION_TYPE.OPEN) {
+ this.openAnimationPlayer = animationBuilder.create(targetElement.nativeElement);
+ } else if (type === ANIMATION_TYPE.CLOSE) {
+ this.closeAnimationPlayer = animationBuilder.create(targetElement.nativeElement);
+ }
+ const target = this.getPlayer(type);
+ target.init();
+ this.getPlayer(type).setPosition(1 - oppositePosition);
+ if (type === ANIMATION_TYPE.OPEN) {
+ this.onOpenedCallback = callback;
+ this.openInterrupted = false;
+ } else if (type === ANIMATION_TYPE.CLOSE) {
+ this.onClosedCallback = callback;
+ this.closeInterrupted = false;
+ }
+ const targetEmitter = type === ANIMATION_TYPE.OPEN ? this.openAnimationDone : this.closeAnimationDone;
+ target.onDone(() => {
+ const targetCallback = type === ANIMATION_TYPE.OPEN ? this.onOpenedCallback : this.onClosedCallback;
+ targetCallback();
+ if (!(type === ANIMATION_TYPE.OPEN ? this.openInterrupted : this.closeInterrupted)) {
+ targetEmitter.emit();
+ }
+ this.cleanUpPlayer(type);
+ });
+ return target;
+ }
+
+
+ private cleanUpPlayer(target: ANIMATION_TYPE) {
+ switch (target) {
+ case ANIMATION_TYPE.CLOSE:
+ if (this.closeAnimationPlayer != null) {
+ this.closeAnimationPlayer.reset();
+ this.closeAnimationPlayer.destroy();
+ this.closeAnimationPlayer = null;
+ }
+ this.closeInterrupted = true;
+ this.onClosedCallback = this._defaultClosedCallback;
+ break;
+ case ANIMATION_TYPE.OPEN:
+ if (this.openAnimationPlayer != null) {
+ this.openAnimationPlayer.reset();
+ this.openAnimationPlayer.destroy();
+ this.openAnimationPlayer = null;
+ }
+ this.openInterrupted = true;
+ this.onOpenedCallback = this._defaultOpenedCallback;
+ break;
+ default:
+ break;
+ }
+ }
+
+ private getPlayer(type: ANIMATION_TYPE): AnimationPlayer {
+ switch (type) {
+ case ANIMATION_TYPE.OPEN:
+ return this.openAnimationPlayer;
+ case ANIMATION_TYPE.CLOSE:
+ return this.closeAnimationPlayer;
+ default:
+ return null;
+ }
+ }
+}
diff --git a/projects/igniteui-angular/src/lib/services/overlay/overlay.ts b/projects/igniteui-angular/src/lib/services/overlay/overlay.ts
index f32907cd314..67c9e4bda85 100644
--- a/projects/igniteui-angular/src/lib/services/overlay/overlay.ts
+++ b/projects/igniteui-angular/src/lib/services/overlay/overlay.ts
@@ -723,6 +723,7 @@ export class IgxOverlayService implements OnDestroy {
// is done, 0.75 if 3/4 of the animation is done. As we need to start next animation from where
// the previous has finished we need the amount up to 1, therefore we are subtracting what
// getPosition() returns from one
+ // TODO: This assumes opening and closing animations are mirrored.
const position = 1 - info.openAnimationInnerPlayer.getPosition();
info.openAnimationPlayer.reset();
// calling reset does not change hasStarted to false. This is why we are doing it her via internal field
diff --git a/projects/igniteui-angular/src/lib/tree/README.md b/projects/igniteui-angular/src/lib/tree/README.md
new file mode 100644
index 00000000000..24fe2ed8dbb
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/README.md
@@ -0,0 +1,160 @@
+# IgxTreeComponent
+
+## Description
+_igx-tree component allows you to render hierarchical data in an easy-to-navigate view. Declaring a tree is done by using `igx-tree` and specifying its `igx-tree-nodes`:_
+
+- *`igx-tree`* - The tree container. Consists of a tree root that renders all passed `igx-tree-node`s
+- *`igx-tree-node`* - A single node for the tree. Renders its content as-is. Houses other `igx-tree-node`s.
+- *`[igxTreeNodeLink]`* - A directive that should be put on **any** link child of an `igx-tree-node`, to ensure proper ARIA attributes and navigation
+- *`[igxTreeNodeExpandIndicator]`* - A directive that can be passed to an `ng-template` within the `igx-tree`. The template will be used to render parent nodes' `expandIndicator`
+
+A complete walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tree).
+The specification for the tree can be found [here](https://github.com/IgniteUI/igniteui-angular/wiki/Tree-Specification)
+
+----------
+
+## Usage
+```html
+
+
+ {{ node.text }}
+
+
+ {{ child.text }}
+
+ {{ leafChild.text }}
+
+
+
+
+```
+
+----------
+
+## Keyboard Navigation
+
+The keyboard can be used to navigate through all nodes in the tree.
+The control distinguishes two states - `focused` and `active`.
+The focused node is where all events are fired and from where navigation will begin/continue. Focused nodes are marked with a distinct style.
+The active node, in most cases, is the last node on which user interaction took place. Active nodes also have a distinct style. Active nodes can be used to better accent a node in that tree that indicates the app's current state (e.g. a current route in the app when using a tree as a navigation component).
+
+In most cases, moving the focused node also moves the active node.
+
+When navigating to nodes that are outside of view, if the tree (`igx-tree` tag) has a scrollbar, scrolls the focused node into view.
+When finishing state transition animations (expand/collapse), if the target node is outside of view AND if the tree (`igx-tree` tag) has a scrollbar, scrolls the focused node into view.
+When initializing the tree and a node is marked as active, if that node is outside of view AND if the tree (`igx-tree` tag) has a scrollbar, scrolls the activated node into view.
+
+_FIRST and LAST node refers to the respective visible node WITHOUT expanding/collapsing any existing node._
+
+_Disabled nodes are not counted as visible nodes for the purpose of keyboard navigation._
+
+|Keys |Description| Activates Node |
+|---------------|-----------|-----------|
+| ARROW DOWN | Moves to the next visible node. Does nothing if on the LAST node. | true |
+| CTRL + ARROW DOWN | Performs the same as ARROW DOWN. | false |
+| ARROW UP | Moves to the previous visible node. Does nothing if on the FIRST node. | true |
+| CTRL + ARROW UP | Performs the same as ARROW UP. | false |
+| TAB | Navigate to the next focusable element on the page, outside of the tree.* | false |
+| SHIFT + TAB | Navigate to the previous focusable element on the page, outside of the tree.* | false |
+| HOME | Navigates to the FIRST node. | true |
+| END | Navigates to the LAST node. | true |
+| ARROW RIGHT | On an **expanded** parent node, navigates to the first child of the node. If on a **collapsed** parent node, expands it. | true |
+| ARROW LEFT | On an **expanded** parent node, collapses it. If on a child node, moves to its parent node. | true |
+| SPACE | Toggles selection of the current node. Marks the node as active. | true |
+| * | Expand the node and all sibling nodes on the same level w/ children | true |
+| CLICK | Focuses the node | true |
+
+When selection is enabled, end-user selection of nodes is **only allowed through the displayed checkbox**. Since both selection types allow multiple selection, the following mouse + keyboard interaction is available:
+
+| Combination |Description| Activates Node |
+|---------------|-----------|-----------|
+| SHIFT + CLICK / SPACE | when multiple selection is enabled, toggles selection of all nodes between the active one and the one clicked while holding SHIFT. | true |
+
+----------
+
+## API Summary
+
+### IgxTreeComponent
+
+#### Accessors
+
+**Get**
+
+ | Name | Description | Type |
+ |----------------|------------------------------------------------------------------------------|---------------------|
+ | rootNodes | Returns all of the tree's nodes that are on root level | `IgxTreeNodeComponent[]` |
+
+
+#### Properties
+
+ | Name | Description | Type |
+ |----------------|------------------------------------------------------------------------------|----------------------------------------|
+ | selection | The selection state of the tree | `"None"` \| `"BiState"` \| `"Cascading"` |
+ | animationSettings | The setting for the animation when opening / closing a node | `{ openAnimation: AnimationMetadata, closeAnimation: AnimationMetadata }` |
+ | singleBranchExpand | Whether a single or multiple of a parent's child nodes can be expanded. Default is `false` | `boolean` |
+ | expandIndicator | Get\Set a reference to a custom template that should be used for rendering the expand/collapse indicators of nodes. | `TemplateRef` |
+ | displayDensity | Get\Set the display density of the tree. Affects all child nodes | `DisplayDensity` |
+
+#### Methods
+ | Name | Description | Parameters | Returns |
+ |-----------------|----------------------------|-------------------------|--------|
+ | findNodes | Returns an array of nodes which match the specified data. `[data]` input should be specified in order to find nodes. A custom comparer function can be specified for custom search (e.g. by a specific value key). Returns `null` if **no** nodes match | `data: T\|, comparer?: (data: T, node: IgxTreeNodeComponent) => boolean` | `IgxTreeNodeComponent[]` \| `null` |
+ | deselectAll | Deselects all nodes. If a nodes array is passed, deselects only the specified nodes. **Does not** emit `nodeSelection` event. | `nodes?: IgxTreeNodeComponent[]` | `void` |
+ | collapseAll | Collapses the specified nodes. If no nodes passed, collapses **all parent nodes**. | `nodes?: IgxTreeNodeComponent[]` | `void` |
+ | expandAll | Sets the specified nodes as expanded. If no nodes passed, expands **all parent nodes**. | `nodes?: IgxTreeNodeComponent[]` | `void` |
+
+#### Events
+
+ | Name | Description | Cancelable | Arguments |
+ |----------------|-------------------------------------------------------------------------|------------|------------|
+ | nodeSelection | Emitted when item selection is changing, before the selection completes | true | `{ owner: IgxTreeComponent, newSelection: IgxTreeNodeComponent[], oldSelection: IgxTreeNodeComponent[], added: IgxTreeNodeComponent[], removed: IgxTreeNodeComponent[], cancel: true }` |
+ | nodeCollapsed | Emitted when node collapsing animation finishes and node is collapsed. | false | `{ node: IgxTreeNodeComponent, owner: IgxTreeComponent }` |
+ | nodeCollapsing | Emitted when node collapsing animation starts, when `node.expanded` is set to transition from `true` to `false`. | true | `{ node: IgxTreeNodeComponent, owner: IgxTreeComponent, cancel: boolean }` |
+ | nodeExpanded | Emitted when node expanding animation finishes and node is expanded. | false | `{ node: IgxTreeNodeComponent, owner: IgxTreeComponent }` |
+ | nodeExpanding | Emitted when node expanding animation starts, when `node.expanded` is set to transition from `false` to `true`. | true | `node: IgxTreeNodeComponent, owner: IgxTreeComponent, cancel: boolean }` |
+ | activeNodeChanged | Emitted when the tree's `active` node changes | false | `IgxTreeNodeComponent` |
+ | onDensityChanged | Emitted when the display density of the tree is changed | false | `{ oldDensity: DisplayDensity, newDensity: DisplayDensity }` |
+
+### IgxTreeNodeComponent
+
+#### Accessors
+
+**Get**
+
+ | Name | Description | Type |
+ |-----------------|-------------------------------------------------------------------------------|---------------------|
+ | parentNode | The parent node of the current node (if any) | `IgxTreeNodeComponent` |
+ | path | The full path to the node, starting from the top-most ancestor | `IgxTreeNodeComponent[]` |
+ | level | The "depth" of the node. If root node - 0, if a child of parent - `parent.level` + 1 | `number` |
+ | tree | A reference to the tree the node is a part of | `IgxTreecomponent` |
+ | children | A collection of child nodes. `null` if node does not have children | `IgxTreeNodeComponent[]` \| `null` |
+
+#### Properties
+
+ | Name | Description | Type |
+ |-----------------|-------------------------------------------------------------------------------|---------------------|
+ | disabled | Get/Set whether the node is disabled. Disabled nodes are ignore for user interactions. | `boolean` |
+ | expanded | The node expansion state. Does not trigger animation. | `boolean` \| `null` |
+ | selected | The node selection state. | `boolean` |
+ | data | The data entry that the node is visualizing. Required for searching through nodes. | `T` |
+ | active | Marks the node as the tree's active node | `boolean` |
+ | resourceStrings | An accessor for the current resource strings used for the node | `ITreeResourceStrings` |
+ | loading | Specifies whether the node is loading data. Loading nodes do not render children. To be used for load-on-demand scenarios | `boolean` |
+
+
+#### Methods
+
+ | Name | Description | Parameters | Returns |
+ |-----------------|-------------------------------------------------------------------------------|------------|---------|
+ | expand | Expands the node, triggering animations | None | `void` |
+ | collapse | Collapses the node, triggering animations | None | `void` |
+ | toggle| Toggles node expansion state, triggering animations | None | `void` |\
+
+#### Events
+
+ | Name | Description | Cancelable | Parameters |
+ |-----------------|-------------------------------------------------------------------------------|------------|---------|
+ | expandedChange | Emitted when the node's `expanded` property changes | false | `boolean` |
+ | selectedChange | Emitted when the node's `selected` property changes | false | `boolean` |
+
+
diff --git a/projects/igniteui-angular/src/lib/tree/common.ts b/projects/igniteui-angular/src/lib/tree/common.ts
new file mode 100644
index 00000000000..8b3aba7992b
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/common.ts
@@ -0,0 +1,113 @@
+import { ElementRef, EventEmitter, InjectionToken, QueryList, TemplateRef } from '@angular/core';
+import { DisplayDensity } from '../core/displayDensity';
+import { IBaseCancelableBrowserEventArgs, IBaseEventArgs, mkenum } from '../core/utils';
+import { ToggleAnimationSettings } from '../expansion-panel/toggle-animation-component';
+
+// Component interfaces
+
+/** Comparer function that can be used when searching through IgxTreeNode[] */
+export type IgxTreeSearchResolver = (data: any, node: IgxTreeNode) => boolean;
+
+export interface IgxTree {
+ /** @hidden @internal */
+ nodes: QueryList>;
+ /** @hidden @internal */
+ rootNodes: IgxTreeNode[];
+ singleBranchExpand: boolean;
+ selection: IgxTreeSelectionType;
+ expandIndicator: TemplateRef;
+ animationSettings: ToggleAnimationSettings;
+ /** @hidden @internal */
+ displayDensity: DisplayDensity;
+ /** @hidden @internal */
+ forceSelect: IgxTreeNode[];
+ /** @hidden @internal */
+ disabledChange: EventEmitter>;
+ /** @hidden @internal */
+ activeNodeBindingChange: EventEmitter>;
+ nodeSelection: EventEmitter;
+ nodeExpanding: EventEmitter;
+ nodeExpanded: EventEmitter;
+ nodeCollapsing: EventEmitter;
+ nodeCollapsed: EventEmitter;
+ activeNodeChanged: EventEmitter>;
+ expandAll(nodes: IgxTreeNode[]): void;
+ collapseAll(nodes: IgxTreeNode[]): void;
+ deselectAll(node?: IgxTreeNode[]): void;
+ findNodes(searchTerm: any, comparer?: IgxTreeSearchResolver): IgxTreeNode[] | null;
+}
+
+// Item interfaces
+export interface IgxTreeNode {
+ parentNode?: IgxTreeNode | null;
+ loading: boolean;
+ path: IgxTreeNode[];
+ expanded: boolean | null;
+ /** @hidden @internal */
+ indeterminate: boolean;
+ selected: boolean | null;
+ disabled: boolean;
+ /** @hidden @internal */
+ isFocused: boolean;
+ active: boolean;
+ level: number;
+ data: T;
+ /** @hidden @internal */
+ nativeElement: HTMLElement;
+ /** @hidden @internal */
+ header: ElementRef;
+ /** @hidden @internal */
+ tabIndex: number;
+ /** @hidden @internal */
+ allChildren: QueryList>;
+ /** @hidden @internal */
+ _children: QueryList> | null;
+ selectedChange: EventEmitter;
+ expandedChange: EventEmitter;
+ expand(): void;
+ collapse(): void;
+ toggle(): void;
+ /** @hidden @internal */
+ addLinkChild(node: any): void;
+ /** @hidden @internal */
+ removeLinkChild(node: any): void;
+}
+
+// Events
+export interface ITreeNodeSelectionEvent extends IBaseCancelableBrowserEventArgs {
+ oldSelection: IgxTreeNode[];
+ newSelection: IgxTreeNode[];
+ added: IgxTreeNode[];
+ removed: IgxTreeNode[];
+ event?: Event;
+}
+
+export interface ITreeNodeEditingEvent extends IBaseCancelableBrowserEventArgs {
+ node: IgxTreeNode;
+ value: string;
+}
+
+export interface ITreeNodeEditedEvent extends IBaseEventArgs {
+ node: IgxTreeNode;
+ value: any;
+}
+
+export interface ITreeNodeTogglingEventArgs extends IBaseEventArgs, IBaseCancelableBrowserEventArgs {
+ node: IgxTreeNode;
+}
+
+export interface ITreeNodeToggledEventArgs extends IBaseEventArgs {
+ node: IgxTreeNode;
+}
+
+// Enums
+export const IgxTreeSelectionType = mkenum({
+ None: 'None',
+ BiState: 'BiState',
+ Cascading: 'Cascading'
+});
+export type IgxTreeSelectionType = (typeof IgxTreeSelectionType)[keyof typeof IgxTreeSelectionType];
+
+// Token
+export const IGX_TREE_COMPONENT = new InjectionToken('IgxTreeToken');
+export const IGX_TREE_NODE_COMPONENT = new InjectionToken>('IgxTreeNodeToken');
diff --git a/projects/igniteui-angular/src/lib/tree/public_api.ts b/projects/igniteui-angular/src/lib/tree/public_api.ts
new file mode 100644
index 00000000000..08b8f6258c9
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/public_api.ts
@@ -0,0 +1,5 @@
+export * from './tree.component';
+export * from './tree-node/tree-node.component';
+export { IgxTreeSearchResolver, ITreeNodeSelectionEvent, ITreeNodeEditingEvent,
+ ITreeNodeEditedEvent, ITreeNodeTogglingEventArgs, ITreeNodeToggledEventArgs,
+ IgxTreeSelectionType, IgxTree, IgxTreeNode } from './common';
diff --git a/projects/igniteui-angular/src/lib/tree/tree-functions.spec.ts b/projects/igniteui-angular/src/lib/tree/tree-functions.spec.ts
new file mode 100644
index 00000000000..874eb45da56
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-functions.spec.ts
@@ -0,0 +1,88 @@
+import { EventEmitter, QueryList } from '@angular/core';
+import { ComponentFixture } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { IgxTreeNode } from './common';
+import { IgxTreeNodeComponent } from './tree-node/tree-node.component';
+
+export const TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS = 'igx-tree-node__select';
+const CHECKBOX_INPUT_CSS_CLASS = '.igx-checkbox__input';
+const TREE_NODE_CSS_CLASS = 'igx-tree-node';
+
+export class TreeTestFunctions {
+
+ public static getAllNodes(fix: ComponentFixture) {
+ return fix.debugElement.queryAll(By.css(`.${TREE_NODE_CSS_CLASS}`));
+ }
+
+ public static getNodeCheckboxDiv(nodeDOM: HTMLElement): HTMLElement {
+ return nodeDOM.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`);
+ }
+
+ public static getNodeCheckboxInput(nodeDOM: HTMLElement): HTMLInputElement {
+ return TreeTestFunctions.getNodeCheckboxDiv(nodeDOM).querySelector(CHECKBOX_INPUT_CSS_CLASS);
+ }
+
+ public static clickNodeCheckbox(node: IgxTreeNodeComponent): Event {
+ const checkboxElement = TreeTestFunctions.getNodeCheckboxDiv(node.nativeElement);
+ const event = new Event('click', {});
+ checkboxElement.dispatchEvent(event);
+ return event;
+ }
+
+ public static verifyNodeSelected(node: IgxTreeNodeComponent, selected = true, hasCheckbox = true, indeterminate = false) {
+ expect(node.selected).toBe(selected);
+ expect(node.indeterminate).toBe(indeterminate);
+ if (hasCheckbox) {
+ expect(this.getNodeCheckboxDiv(node.nativeElement)).not.toBeNull();
+ expect(TreeTestFunctions.getNodeCheckboxInput(node.nativeElement).checked).toBe(selected);
+ expect(TreeTestFunctions.getNodeCheckboxInput(node.nativeElement).indeterminate).toBe(indeterminate);
+ } else {
+ expect(this.getNodeCheckboxDiv(node.nativeElement)).toBeNull();
+ }
+ }
+
+ public static createNodeSpy(
+ properties: { [key: string]: any } = null,
+ methodNames: (keyof IgxTreeNode)[] = ['selected']): jasmine.SpyObj> {
+ if (!properties) {
+ return jasmine.createSpyObj>(methodNames);
+ }
+ return jasmine.createSpyObj>(methodNames, properties);
+ }
+
+ public static createNodeSpies(
+ level: number,
+ count: number,
+ parentNode?: IgxTreeNodeComponent,
+ children?: any[],
+ allChildren?: any[]
+ ): IgxTreeNodeComponent[] {
+ const nodesArr = [];
+ const mockEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']);
+ for (let i = 0; i < count; i++) {
+ nodesArr.push(this.createNodeSpy({
+ level,
+ expanded: false,
+ disabled: false,
+ tabIndex: null,
+ header: { nativeElement: { focus: () => undefined } },
+ parentNode: parentNode ? parentNode : null,
+ _children: children ? children[i] : null,
+ allChildren: allChildren ? allChildren[i] : null,
+ selectedChange: mockEmitter
+ }));
+ }
+ return nodesArr;
+ }
+
+ public static createQueryListSpy(nodes: IgxTreeNodeComponent[]): jasmine.SpyObj>> {
+ const mockQuery = jasmine.createSpyObj(['toArray', 'filter', 'forEach']);
+ Object.defineProperty(mockQuery, 'first', { value: nodes[0], enumerable: true });
+ Object.defineProperty(mockQuery, 'last', { value: nodes[nodes.length - 1], enumerable: true });
+ Object.defineProperty(mockQuery, 'length', { value: nodes.length, enumerable: true });
+ mockQuery.toArray.and.returnValue(nodes);
+ mockQuery.filter.and.callFake((cb) => nodes.filter(cb));
+ mockQuery.forEach.and.callFake((cb) => nodes.forEach(cb));
+ return mockQuery;
+ };
+}
diff --git a/projects/igniteui-angular/src/lib/tree/tree-navigation.service.ts b/projects/igniteui-angular/src/lib/tree/tree-navigation.service.ts
new file mode 100644
index 00000000000..ddc31d9cb0c
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-navigation.service.ts
@@ -0,0 +1,252 @@
+import { Injectable, OnDestroy } from '@angular/core';
+import { IgxTree, IgxTreeNode, IgxTreeSelectionType } from './common';
+import { NAVIGATION_KEYS } from '../core/utils';
+import { IgxTreeService } from './tree.service';
+import { IgxTreeSelectionService } from './tree-selection.service';
+import { Subject } from 'rxjs';
+
+/** @hidden @internal */
+@Injectable()
+export class IgxTreeNavigationService implements OnDestroy {
+ private tree: IgxTree;
+
+ private _focusedNode: IgxTreeNode = null;
+ private _lastFocusedNode: IgxTreeNode = null;
+ private _activeNode: IgxTreeNode = null;
+
+ private _visibleChildren: IgxTreeNode[] = [];
+ private _invisibleChildren: Set> = new Set();
+ private _disabledChildren: Set> = new Set();
+
+ private _cacheChange = new Subject();
+
+ constructor(private treeService: IgxTreeService, private selectionService: IgxTreeSelectionService) {
+ this._cacheChange.subscribe(() => {
+ this._visibleChildren =
+ this.tree?.nodes ?
+ this.tree.nodes.filter(e => !(this._invisibleChildren.has(e) || this._disabledChildren.has(e))) :
+ [];
+ });
+ }
+
+ public register(tree: IgxTree) {
+ this.tree = tree;
+ }
+
+ public get focusedNode() {
+ return this._focusedNode;
+ }
+
+ public set focusedNode(value: IgxTreeNode) {
+ if (this._focusedNode === value) {
+ return;
+ }
+ this._lastFocusedNode = this._focusedNode;
+ if (this._lastFocusedNode) {
+ this._lastFocusedNode.tabIndex = -1;
+ }
+ this._focusedNode = value;
+ if (this._focusedNode !== null) {
+ this._focusedNode.tabIndex = 0;
+ this._focusedNode.header.nativeElement.focus();
+ }
+ }
+
+ public get activeNode() {
+ return this._activeNode;
+ }
+
+ public set activeNode(value: IgxTreeNode) {
+ if (this._activeNode === value) {
+ return;
+ }
+ this._activeNode = value;
+ this.tree.activeNodeChanged.emit(this._activeNode);
+ }
+
+ public get visibleChildren(): IgxTreeNode[] {
+ return this._visibleChildren;
+ }
+
+ public update_disabled_cache(node: IgxTreeNode): void {
+ if (node.disabled) {
+ this._disabledChildren.add(node);
+ } else {
+ this._disabledChildren.delete(node);
+ }
+ this._cacheChange.next();
+ }
+
+ public init_invisible_cache() {
+ this.tree.nodes.filter(e => e.level === 0).forEach(node => {
+ this.update_visible_cache(node, node.expanded, false);
+ });
+ this._cacheChange.next();
+ }
+
+ public update_visible_cache(node: IgxTreeNode, expanded: boolean, shouldEmit = true): void {
+ if (expanded) {
+ node._children.forEach(child => {
+ this._invisibleChildren.delete(child);
+ this.update_visible_cache(child, child.expanded, false);
+ });
+ } else {
+ node.allChildren.forEach(c => this._invisibleChildren.add(c));
+ }
+
+ if (shouldEmit) {
+ this._cacheChange.next();
+ }
+ }
+
+ /**
+ * Sets the node as focused (and active)
+ *
+ * @param node target node
+ * @param isActive if true, sets the node as active
+ */
+ public setFocusedAndActiveNode(node: IgxTreeNode, isActive: boolean = true): void {
+ if (isActive) {
+ this.activeNode = node;
+ }
+ this.focusedNode = node;
+ }
+
+ /** Handler for keydown events. Used in tree.component.ts */
+ public handleKeydown(event: KeyboardEvent) {
+ const key = event.key.toLowerCase();
+ if (!this.focusedNode) {
+ return;
+ }
+ if (!(NAVIGATION_KEYS.has(key) || key === '*')) {
+ if (key === 'enter') {
+ this.activeNode = this.focusedNode;
+ }
+ return;
+ }
+ event.preventDefault();
+ if (event.repeat) {
+ setTimeout(() => this.handleNavigation(event), 1);
+ } else {
+ this.handleNavigation(event);
+ }
+ }
+
+ public ngOnDestroy() {
+ this._cacheChange.next();
+ this._cacheChange.complete();
+ }
+
+ private handleNavigation(event: KeyboardEvent) {
+ switch (event.key.toLowerCase()) {
+ case 'home':
+ this.setFocusedAndActiveNode(this.visibleChildren[0]);
+ break;
+ case 'end':
+ this.setFocusedAndActiveNode(this.visibleChildren[this.visibleChildren.length - 1]);
+ break;
+ case 'arrowleft':
+ case 'left':
+ this.handleArrowLeft();
+ break;
+ case 'arrowright':
+ case 'right':
+ this.handleArrowRight();
+ break;
+ case 'arrowup':
+ case 'up':
+ this.handleUpDownArrow(true, event);
+ break;
+ case 'arrowdown':
+ case 'down':
+ this.handleUpDownArrow(false, event);
+ break;
+ case '*':
+ this.handleAsterisk();
+ break;
+ case ' ':
+ case 'spacebar':
+ case 'space':
+ this.handleSpace(event.shiftKey);
+ break;
+ default:
+ return;
+ }
+ }
+
+ private handleArrowLeft(): void {
+ if (this.focusedNode.expanded && !this.treeService.collapsingNodes.has(this.focusedNode) && this.focusedNode._children?.length) {
+ this.activeNode = this.focusedNode;
+ this.focusedNode.collapse();
+ } else {
+ const parentNode = this.focusedNode.parentNode;
+ if (parentNode && !parentNode.disabled) {
+ this.setFocusedAndActiveNode(parentNode);
+ }
+ }
+ }
+
+ private handleArrowRight(): void {
+ if (this.focusedNode._children.length > 0) {
+ if (!this.focusedNode.expanded) {
+ this.activeNode = this.focusedNode;
+ this.focusedNode.expand();
+ } else {
+ if (this.treeService.collapsingNodes.has(this.focusedNode)) {
+ this.focusedNode.expand();
+ return;
+ }
+ const firstChild = this.focusedNode._children.find(node => !node.disabled);
+ if (firstChild) {
+ this.setFocusedAndActiveNode(firstChild);
+ }
+ }
+ }
+ }
+
+ private handleUpDownArrow(isUp: boolean, event: KeyboardEvent): void {
+ const next = this.getVisibleNode(this.focusedNode, isUp ? -1 : 1);
+ if (next === this.focusedNode) {
+ return;
+ }
+
+ if (event.ctrlKey) {
+ this.setFocusedAndActiveNode(next, false);
+ } else {
+ this.setFocusedAndActiveNode(next);
+ }
+ }
+
+ private handleAsterisk(): void {
+ const nodes = this.focusedNode.parentNode ? this.focusedNode.parentNode._children : this.tree.rootNodes;
+ nodes?.forEach(node => {
+ if (!node.disabled && (!node.expanded || this.treeService.collapsingNodes.has(node))) {
+ node.expand();
+ }
+ });
+ }
+
+ private handleSpace(shiftKey = false): void {
+ if (this.tree.selection === IgxTreeSelectionType.None) {
+ return;
+ }
+
+ this.activeNode = this.focusedNode;
+ if (shiftKey) {
+ this.selectionService.selectMultipleNodes(this.focusedNode);
+ return;
+ }
+
+ if (this.focusedNode.selected) {
+ this.selectionService.deselectNode(this.focusedNode);
+ } else {
+ this.selectionService.selectNode(this.focusedNode);
+ }
+ }
+
+ /** Gets the next visible node in the given direction - 1 -> next, -1 -> previous */
+ private getVisibleNode(node: IgxTreeNode, dir: 1 | -1 = 1): IgxTreeNode {
+ const nodeIndex = this.visibleChildren.indexOf(node);
+ return this.visibleChildren[nodeIndex + dir] || node;
+ }
+}
diff --git a/projects/igniteui-angular/src/lib/tree/tree-navigation.spec.ts b/projects/igniteui-angular/src/lib/tree/tree-navigation.spec.ts
new file mode 100644
index 00000000000..1e2e1e27125
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-navigation.spec.ts
@@ -0,0 +1,775 @@
+import { configureTestSuite } from '../test-utils/configure-suite';
+import { waitForAsync, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { IgxTreeNavigationComponent, IgxTreeScrollComponent } from './tree-samples.spec';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { UIInteractions, wait } from '../test-utils/ui-interactions.spec';
+import { IgxTreeNavigationService } from './tree-navigation.service';
+import { ElementRef, EventEmitter } from '@angular/core';
+import { IgxTreeSelectionService } from './tree-selection.service';
+import { TreeTestFunctions } from './tree-functions.spec';
+import { IgxTreeService } from './tree.service';
+import { IgxTreeComponent, IgxTreeModule } from './tree.component';
+import { IgxTree, IgxTreeNode, IgxTreeSelectionType } from './common';
+import { IgxTreeNodeComponent } from './tree-node/tree-node.component';
+
+describe('IgxTree - Navigation #treeView', () => {
+ configureTestSuite();
+
+ describe('Navigation - UI Tests', () => {
+ let fix;
+ let tree: IgxTreeComponent;
+ beforeAll(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ IgxTreeNavigationComponent,
+ IgxTreeScrollComponent
+ ],
+ imports: [IgxTreeModule, NoopAnimationsModule]
+ }).compileComponents();
+ }));
+
+ beforeEach(fakeAsync(() => {
+ fix = TestBed.createComponent(IgxTreeNavigationComponent);
+ fix.detectChanges();
+ tree = fix.componentInstance.tree;
+ }));
+
+ describe('UI Interaction tests - None', () => {
+ beforeEach(fakeAsync(() => {
+ tree.selection = IgxTreeSelectionType.None;
+ fix.detectChanges();
+ }));
+
+ it('Initial tab index without focus SHOULD be 0 for all nodes and active input should be set correctly', () => {
+ const visibleNodes = (tree as any).navService.visibleChildren;
+ visibleNodes.forEach(node => {
+ expect(node.header.nativeElement.tabIndex).toEqual(0);
+ });
+
+ // Should render node with `node.active === true`, set through input, as active in the tree
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]);
+ expect(tree.nodes.toArray()[17].active).toBeTruthy();
+
+ tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+ visibleNodes.forEach(node => {
+ if (node !== tree.nodes.first) {
+ expect(node.header.nativeElement.tabIndex).toEqual(-1);
+ } else {
+ expect(node.header.nativeElement.tabIndex).toEqual(0);
+ }
+ });
+ });
+
+ it('Should focus/activate correct node on ArrowDown/ArrowUp (+ Ctrl) key pressed', () => {
+ spyOn(tree.activeNodeChanged, 'emit').and.callThrough();
+ tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first);
+
+ // ArrowDown + Ctrl should only focus the next visible node
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+
+ // ArrowDown should focus and activate the next visible node
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[28]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[28]);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[28]);
+
+ // ArrowUp + Ctrl should only focus the previous visible node
+ UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[28].nativeElement, true, false, false, true);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[28]);
+
+ // ArrowUp should focus and activate the previous visible node
+ UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[17].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first);
+ });
+
+ it('Should focus and activate the first/last visible node on Home/End key press', () => {
+ spyOn(tree.activeNodeChanged, 'emit').and.callThrough();
+ tree.nodes.first.expand();
+ fix.detectChanges();
+ tree.nodes.toArray()[2].header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('home', tree.nodes.toArray()[2].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first);
+
+ UIInteractions.triggerKeyDownEvtUponElem('end', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.last);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.last);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.last);
+ });
+
+ it('Should collapse/navigate to correct node on Arrow left key press', fakeAsync(() => {
+ spyOn(tree.activeNodeChanged, 'emit').and.callThrough();
+ // If node is collapsed and has no parents the focus and activation should not be moved on Arrow left key press
+ tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ tick();
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.first.nativeElement);
+ tick();
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first);
+
+ // If node is collapsed and has parent the focus and activation should be moved to the parent node on Arrow left key press
+ tree.nodes.first.expand();
+ tick();
+ fix.detectChanges();
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement);
+ tick();
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.first.nativeElement);
+ tick();
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first);
+
+ // If node is expanded the node should collapse on Arrow left key press
+ UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.first.nativeElement);
+ tick();
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+ expect(tree.nodes.first.expanded).toBeFalsy();
+ }));
+
+ it('Should expand/navigate to correct node on Arrow right key press', () => {
+ spyOn(tree.activeNodeChanged, 'emit').and.callThrough();
+ // If node has no children the focus and activation should not be moved on Arrow right key press
+ tree.nodes.last.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.last.nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.last);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.last);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.last);
+
+ // If node is collapsed and has children the node should be expanded on Arrow right key press
+ UIInteractions.triggerKeyDownEvtUponElem('home', tree.nodes.last.nativeElement);
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first);
+ expect(tree.nodes.first.expanded).toBeTruthy();
+
+ // If node is expanded and has children the focus and activation should be moved to the first child on Arrow right key press
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[1]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[1]);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[1]);
+ });
+
+ it('Pressing Asterisk on focused node should expand all expandable nodes in the same group', () => {
+ tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('*', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ expect(tree.nodes.toArray()[2].expanded).toBeTruthy();
+ expect(tree.nodes.toArray()[12].expanded).toBeTruthy();
+ });
+
+ it('Pressing Enter should activate the focused node and not prevent the keydown event`s deafault behavior', () => {
+ spyOn(tree.activeNodeChanged, 'emit').and.callThrough();
+ tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true);
+ fix.detectChanges();
+
+ const mockEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
+ spyOn(mockEvent, 'preventDefault');
+ tree.nodes.toArray()[17].nativeElement.dispatchEvent(mockEvent);
+ expect(mockEvent.preventDefault).not.toHaveBeenCalled();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[17]);
+ });
+
+ it('Should correctly set node`s selection state on Space key press', () => {
+ spyOn(tree.activeNodeChanged, 'emit').and.callThrough();
+ // Space on None Selection Mode
+ tree.selection = 'None';
+ tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true);
+ fix.detectChanges();
+
+ spyOn((tree as any).selectionService, 'selectNode').and.callThrough();
+ spyOn((tree as any).selectionService, 'deselectNode').and.callThrough();
+ spyOn((tree as any).selectionService, 'selectMultipleNodes').and.callThrough();
+
+ UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.toArray()[17].nativeElement);
+ fix.detectChanges();
+
+ expect(tree.nodes.toArray()[17].selected).toBeFalsy();
+ expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(0);
+ expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(0);
+ expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(0);
+ expect((tree as any).navService.activeNode).not.toEqual(tree.nodes.toArray()[17]);
+
+ // Space for select
+ tree.selection = 'BiState';
+ UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.toArray()[17].nativeElement);
+ fix.detectChanges();
+
+ expect(tree.nodes.toArray()[17].selected).toBeTruthy();
+ expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(1);
+ expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(0);
+ expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(0);
+ expect((tree as any).selectionService.selectNode).toHaveBeenCalledWith(tree.nodes.toArray()[17]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]);
+ expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[17]);
+
+ // Space with Shift key
+ UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[17].nativeElement, true, false, false, true);
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.first.nativeElement, true, false, true, false);
+ fix.detectChanges();
+
+ expect(tree.nodes.first.selected).toBeTruthy();
+ expect(tree.nodes.toArray()[17].selected).toBeTruthy();
+ expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(1);
+ expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(0);
+ expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(1);
+ expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledWith(tree.nodes.first);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.first);
+
+ // Space for deselect
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true);
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.toArray()[17].nativeElement);
+ fix.detectChanges();
+
+ expect(tree.nodes.toArray()[17].selected).toBeFalsy();
+ expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(1);
+ expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(1);
+ expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(1);
+ expect((tree as any).selectionService.deselectNode).toHaveBeenCalledWith(tree.nodes.toArray()[17]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]);
+ });
+ });
+
+ describe('UI Interaction tests - Scroll to focused node', () => {
+ beforeEach(fakeAsync(() => {
+ fix = TestBed.createComponent(IgxTreeScrollComponent);
+ fix.detectChanges();
+ tree = fix.componentInstance.tree;
+ tree.selection = IgxTreeSelectionType.None;
+ fix.detectChanges();
+ }));
+
+ it('The tree container should be scrolled so that the focused node is in view', fakeAsync(() => {
+ // set another node as active element, expect node to be in view
+ tick();
+ const treeElement = tree.nativeElement;
+ let targetNode = tree.nodes.last;
+ let nodeElement = targetNode.nativeElement;
+ let nodeRect = nodeElement.getBoundingClientRect();
+ let treeRect = treeElement.getBoundingClientRect();
+ // expect node is in view
+ expect((treeRect.top > nodeRect.top) || (treeRect.bottom < nodeRect.bottom)).toBeFalsy();
+ targetNode = tree.nodes.first;
+ nodeElement = targetNode?.header.nativeElement;
+ targetNode.active = true;
+ tick();
+ fix.detectChanges();
+ nodeRect = nodeElement.getBoundingClientRect();
+ treeRect = treeElement.getBoundingClientRect();
+ expect(treeElement.scrollTop).toBe(0);
+ expect((treeRect.top > nodeRect.top) || (treeRect.bottom < nodeRect.bottom)).toBeFalsy();
+ let lastNodeIndex = 0;
+ let nodeIndex = 0;
+ for (let i = 0; i < 150; i++) {
+ while (nodeIndex === lastNodeIndex) {
+ nodeIndex = Math.floor(Math.random() * tree.nodes.length);
+ }
+ lastNodeIndex = nodeIndex;
+ targetNode = tree.nodes.toArray()[nodeIndex];
+ nodeElement = targetNode.header.nativeElement;
+ targetNode.active = true;
+ tick();
+ fix.detectChanges();
+ tick();
+ fix.detectChanges();
+ // recalculate rectangles
+ treeRect = treeElement.getBoundingClientRect();
+ nodeRect = targetNode.header.nativeElement.getBoundingClientRect();
+ expect((treeRect.top <= nodeRect.top) && (treeRect.bottom >= nodeRect.bottom)).toBeTruthy();
+ }
+ }));
+ });
+
+ describe('UI Interaction tests - Disabled Nodes', () => {
+ beforeEach(fakeAsync(() => {
+ tree.selection = IgxTreeSelectionType.None;
+ fix.detectChanges();
+ fix.componentInstance.isDisabled = true;
+ fix.detectChanges();
+ }));
+
+ it('TabIndex on disabled node should be -1', () => {
+ expect(tree.nodes.last.header.nativeElement.tabIndex).toEqual(-1);
+ });
+
+ it('Should focus and activate the first/last enabled and visible node on Home/End key press', () => {
+ tree.nodes.first.disabled = true;
+ fix.detectChanges();
+
+ tree.nodes.toArray()[38].header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('home', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]);
+
+ UIInteractions.triggerKeyDownEvtUponElem('end', tree.nodes.toArray()[17].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[38]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[38]);
+ });
+
+ it('Should navigate to correct node on Arrow left/right key press', () => {
+ // If a node is collapsed and has a disabled parent the focus and activation
+ // should not be moved from the node on Arrow left key press
+ tree.nodes.first.expanded = true;
+ fix.detectChanges();
+ tree.nodes.toArray()[2].header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ tree.nodes.first.disabled = true;
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.toArray()[2].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[2]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[2]);
+
+ // If a node is expanded and all its children are disabled the focus and activation
+ // should not be moved from the node on Arrow right key press
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.toArray()[2].nativeElement);
+ fix.detectChanges();
+
+ tree.nodes.toArray()[2]._children.forEach(child => {
+ child.disabled = true;
+ });
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.toArray()[2].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[2]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[2]);
+
+ // If a node is expanded and has enabled children the focus and activation
+ // should be moved to the first enabled child on Arrow right key press
+
+ tree.nodes.toArray()[4].disabled = false;
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.toArray()[2].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[4]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[4]);
+ });
+
+ it('Should navigate to the right node on Arrow up/down key press', () => {
+ tree.nodes.toArray()[28].disabled = true;
+ tree.nodes.toArray()[38].header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[38].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]);
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.toArray()[17].nativeElement);
+ fix.detectChanges();
+
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[38]);
+ expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[38]);
+ });
+
+ it('Pressing Asterisk on focused node should expand only the enabled and expandable nodes in the same group', () => {
+ tree.nodes.toArray()[17].disabled = true;
+ tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('*', tree.nodes.first.nativeElement);
+ fix.detectChanges();
+
+ expect(tree.nodes.toArray()[17].expanded).toBeFalsy();
+ expect(tree.nodes.first.expanded).toBeTruthy();
+ expect(tree.nodes.toArray()[28].expanded).toBeTruthy();
+ expect(tree.nodes.toArray()[38].expanded).toBeTruthy();
+ expect(tree.nodes.last.expanded).toBeFalsy();
+ });
+ });
+
+ describe('UI Interaction tests - igxTreeNodeLink', () => {
+ beforeEach(fakeAsync(() => {
+ tree.selection = IgxTreeSelectionType.None;
+ fix.detectChanges();
+ fix.componentInstance.showNodesWithDirective = true;
+ fix.detectChanges();
+ }));
+
+ it('Nodes with igxTreeNodeLink should have tabIndex -1', () => {
+ expect(tree.nodes.toArray()[41].header.nativeElement.tabIndex).toEqual(-1);
+ expect(tree.nodes.last.header.nativeElement.tabIndex).toEqual(-1);
+ });
+
+ it('When focus falls on link with directive, document.activeElement should be link with directive', fakeAsync(() => {
+ tree.nodes.toArray()[40].header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.toArray()[40].nativeElement);
+ fix.detectChanges();
+ tick();
+ fix.detectChanges();
+
+ const linkNode = tree.nodes.toArray()[41].linkChildren.first.nativeElement;
+ expect(linkNode.tabIndex).toEqual(0);
+
+ // When focus falls on link with directive, parent has focused class (nav.service.focusedNode === link.parent)
+ expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[41]);
+ expect(document.activeElement).toEqual(linkNode);
+ }));
+
+ it('Link with passed parent in ng-template outisde of node parent has proper ref to parent', () => {
+ tree.nodes.toArray()[40].header.nativeElement.dispatchEvent(new Event('pointerdown'));
+ fix.detectChanges();
+
+ tree.nodes.toArray()[46].expanded = true;
+ fix.detectChanges();
+
+ UIInteractions.triggerKeyDownEvtUponElem('end', tree.nodes.toArray()[46].nativeElement);
+ fix.detectChanges();
+
+ expect(tree.nodes.last.registeredChildren[0].tabIndex).toEqual(0);
+ });
+
+ });
+ });
+
+ describe('IgxTreeNavigationSerivce - Unit Tests', () => {
+ let selectionService: IgxTreeSelectionService;
+ let treeService: IgxTreeService;
+ let navService: IgxTreeNavigationService;
+ let mockTree: IgxTree;
+ let mockEmitter: EventEmitter>;
+ let mockNodesLevel1: IgxTreeNodeComponent[];
+ let mockNodesLevel2_1: IgxTreeNodeComponent[];
+ let mockNodesLevel2_2: IgxTreeNodeComponent[];
+ let mockNodesLevel3_1: IgxTreeNodeComponent[];
+ let mockNodesLevel3_2: IgxTreeNodeComponent[];
+ let allNodes: IgxTreeNodeComponent[];
+ const mockQuery1: any = {};
+ const mockQuery2: any = {};
+ const mockQuery3: any = {};
+ const mockQuery4: any = {};
+ const mockQuery5: any = {};
+ const mockQuery6: any = {};
+
+ beforeEach(() => {
+ selectionService = new IgxTreeSelectionService();
+ treeService = new IgxTreeService();
+ navService?.ngOnDestroy();
+ navService = new IgxTreeNavigationService(treeService, selectionService);
+ mockNodesLevel1 = TreeTestFunctions.createNodeSpies(0, 3, null, [mockQuery2, mockQuery3, []], [mockQuery6, mockQuery3, []]);
+ mockNodesLevel2_1 = TreeTestFunctions.createNodeSpies(1, 2,
+ mockNodesLevel1[0], [mockQuery4, mockQuery5], [mockQuery4, mockQuery5]);
+ mockNodesLevel2_2 = TreeTestFunctions.createNodeSpies(1, 1, mockNodesLevel1[1], [[]]);
+ mockNodesLevel3_1 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[0], [[], []]);
+ mockNodesLevel3_2 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[1], [[], []]);
+ allNodes = [
+ mockNodesLevel1[0],
+ mockNodesLevel2_1[0],
+ ...mockNodesLevel3_1,
+ mockNodesLevel2_1[1],
+ ...mockNodesLevel3_2,
+ mockNodesLevel1[1],
+ ...mockNodesLevel2_2,
+ mockNodesLevel1[2]
+ ];
+
+ Object.assign(mockQuery1, TreeTestFunctions.createQueryListSpy(allNodes));
+ Object.assign(mockQuery2, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_1));
+ Object.assign(mockQuery3, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_2));
+ Object.assign(mockQuery4, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_1));
+ Object.assign(mockQuery5, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_2));
+ Object.assign(mockQuery6, TreeTestFunctions.createQueryListSpy([
+ mockNodesLevel2_1[0],
+ ...mockNodesLevel3_1,
+ mockNodesLevel2_1[1],
+ ...mockNodesLevel3_2
+ ]));
+ });
+
+ describe('IgxNavigationService', () => {
+ beforeEach(() => {
+ mockEmitter = jasmine.createSpyObj('emitter', ['emit']);
+ mockTree = jasmine.createSpyObj('tree', [''],
+ { selection: IgxTreeSelectionType.BiState, activeNodeChanged: mockEmitter, nodes: mockQuery1 });
+ navService.register(mockTree);
+ });
+
+ it('Should properly register the specified tree', () => {
+ navService = new IgxTreeNavigationService(treeService, selectionService);
+
+ expect((navService as any).tree).toBeFalsy();
+
+ navService.register(mockTree);
+ expect((navService as any).tree).toEqual(mockTree);
+ });
+
+ it('Should properly calculate VisibleChildren collection', () => {
+ navService.init_invisible_cache();
+ expect(navService.visibleChildren.length).toEqual(3);
+
+ (Object.getOwnPropertyDescriptor(allNodes[0], 'expanded').get as jasmine.Spy)
+ .and.returnValue(true);
+ navService.init_invisible_cache();
+ expect(navService.visibleChildren.length).toEqual(5);
+
+ (Object.getOwnPropertyDescriptor(allNodes[0], 'disabled').get as jasmine.Spy)
+ .and.returnValue(true);
+ navService.update_disabled_cache(allNodes[0]);
+ expect(navService.visibleChildren.length).toEqual(4);
+ allNodes.forEach(e => {
+ (Object.getOwnPropertyDescriptor(e, 'disabled').get as jasmine.Spy)
+ .and.returnValue(true);
+ navService.update_disabled_cache(e);
+ });
+ expect(navService.visibleChildren.length).toEqual(0);
+ mockTree.nodes = null;
+ expect(navService.visibleChildren.length).toEqual(0);
+ });
+
+ it('Should set activeNode and focusedNode correctly', () => {
+ const someNode = {
+ tabIndex: null,
+ header: {
+ nativeElement: jasmine.createSpyObj('nativeElement', ['focus'])
+ }
+ } as any;
+
+ const someNode2 = {
+ tabIndex: null,
+ header: {
+ nativeElement: jasmine.createSpyObj('nativeElement', ['focus'])
+ }
+ } as any;
+
+ navService.focusedNode = someNode;
+ expect(someNode.header.nativeElement.focus).toHaveBeenCalled();
+ expect(someNode.tabIndex).toBe(0);
+
+ navService.setFocusedAndActiveNode(someNode2);
+
+ expect(navService.activeNode).toEqual(someNode2);
+ expect(someNode2.header.nativeElement.focus).toHaveBeenCalled();
+ expect(someNode2.tabIndex).toBe(0);
+ expect(someNode.tabIndex).toBe(-1);
+ expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledTimes(1);
+ expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledWith(someNode2);
+
+ // do not change active node when call w/ same node
+ navService.focusedNode = navService.focusedNode;
+ expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledTimes(1);
+
+ // handle call w/ null
+ navService.focusedNode = null;
+ expect(someNode2.tabIndex).toBe(-1);
+ expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledTimes(1);
+
+ });
+
+ it('Should traverse visibleChildren on handleKeyDown', async () => {
+ navService.init_invisible_cache();
+ const mockEvent1 = new KeyboardEvent('keydown', { key: 'arrowdown', bubbles: true });
+ spyOn(mockEvent1, 'preventDefault');
+ spyOn(navService, 'handleKeydown').and.callThrough();
+ navService.focusedNode = mockNodesLevel1[0];
+
+ navService.handleKeydown(mockEvent1);
+
+ expect(mockEvent1.preventDefault).toHaveBeenCalled();
+ expect(navService.handleKeydown).toHaveBeenCalledTimes(1);
+ expect(navService.focusedNode).toEqual(mockNodesLevel1[1]);
+
+ const mockEvent2 = new KeyboardEvent('keydown', { key: 'arrowup', bubbles: true });
+ spyOn(mockEvent2, 'preventDefault');
+ navService.handleKeydown(mockEvent2);
+
+ expect(mockEvent2.preventDefault).toHaveBeenCalled();
+ expect(navService.handleKeydown).toHaveBeenCalledTimes(2);
+ expect(navService.focusedNode).toEqual(mockNodesLevel1[0]);
+
+ const mockEvent3 = new KeyboardEvent('keydown', { key: 'arrowdown', bubbles: true, repeat: true });
+ spyOn(mockEvent3, 'preventDefault');
+ // when event is repeated, prevent default and wait
+ navService.handleKeydown(mockEvent3);
+ expect(navService.handleKeydown).toHaveBeenCalledTimes(3);
+ expect(mockEvent3.preventDefault).toHaveBeenCalled();
+ // when event is repeating, node does not change immediately
+ expect(navService.focusedNode).toEqual(mockNodesLevel1[0]);
+ await wait(1);
+ expect(navService.focusedNode).toEqual(mockNodesLevel1[1]);
+
+ // does nothing if there is no focused node
+ navService.focusedNode = null;
+ const mockEvent4 = new KeyboardEvent('keydown', { key: 'arrowdown', bubbles: true, repeat: false });
+ spyOn(mockEvent4, 'preventDefault');
+ navService.handleKeydown(mockEvent4);
+ expect(mockEvent4.preventDefault).not.toHaveBeenCalled();
+
+ // do not move focused node if on last node
+ navService.focusedNode = allNodes[allNodes.length - 1];
+ navService.handleKeydown(mockEvent4);
+ expect(navService.focusedNode).toEqual(allNodes[allNodes.length - 1]);
+ });
+
+ it('Should update visible children on all relevant tree events', () => {
+ const mockTreeService = jasmine.createSpyObj('mockSelection',
+ ['register', 'collapse', 'expand', 'collapsing'], {
+ collapsingNodes: jasmine.createSpyObj>>('mockCollpasingSet',
+ ['add', 'delete', 'has'], {
+ size: 0
+ }),
+ expandedNodes: jasmine.createSpyObj>>('mockExpandedSet',
+ ['add', 'delete', 'has'], {
+ size: 0
+ }),
+ });
+ const mockElementRef = jasmine.createSpyObj('mockElement', ['nativeElement'], {
+ nativeElement: jasmine.createSpyObj('mockElement', ['focus'], {
+ clientHeight: 300,
+ scrollHeight: 300
+ })
+ });
+ const mockSelectionService = jasmine.createSpyObj('mockSelection',
+ ['selectNodesWithNoEvent', 'selectMultipleNodes', 'deselectNode', 'selectNode', 'register']);
+ const nav = new IgxTreeNavigationService(mockTreeService, mockSelectionService);
+ const lvl1Nodes = TreeTestFunctions.createNodeSpies(0, 5);
+ const mockQuery = TreeTestFunctions.createQueryListSpy(lvl1Nodes);
+ Object.assign(mockQuery, { changes: new EventEmitter() });
+ spyOn(nav, 'init_invisible_cache');
+ spyOn(nav, 'update_disabled_cache');
+ spyOn(nav, 'update_visible_cache');
+ spyOn(nav, 'register');
+ const tree = new IgxTreeComponent(nav, mockSelectionService, mockTreeService, mockElementRef);
+ tree.nodes = mockQuery;
+ expect(nav.register).toHaveBeenCalledWith(tree);
+ expect(nav.init_invisible_cache).not.toHaveBeenCalled();
+ expect(nav.update_disabled_cache).not.toHaveBeenCalled();
+ expect(nav.update_visible_cache).not.toHaveBeenCalled();
+ // not initialized
+ tree.ngOnInit();
+ // manual call
+ expect(nav.init_invisible_cache).not.toHaveBeenCalled();
+ expect(nav.update_disabled_cache).not.toHaveBeenCalled();
+ expect(nav.update_visible_cache).not.toHaveBeenCalled();
+ // nav service will now be updated after any of the following are emitted
+ const emitNode = tree.nodes.first;
+ tree.disabledChange.emit(emitNode);
+ expect(nav.init_invisible_cache).not.toHaveBeenCalled();
+ expect(nav.update_disabled_cache).toHaveBeenCalledTimes(1);
+ expect(nav.update_visible_cache).toHaveBeenCalledTimes(0);
+ tree.disabledChange.emit(emitNode);
+ expect(nav.update_disabled_cache).toHaveBeenCalledTimes(2);
+ tree.nodeCollapsing.emit({
+ node: emitNode,
+ owner: tree,
+ cancel: false
+ });
+ expect(nav.update_visible_cache).toHaveBeenCalledTimes(1);
+ tree.nodeExpanding.emit({
+ node: emitNode,
+ owner: tree,
+ cancel: false
+ });
+ expect(nav.update_visible_cache).toHaveBeenCalledTimes(2);
+ // attach emitters to mock children
+ lvl1Nodes.forEach(e => {
+ e.expandedChange = new EventEmitter();
+ e.openAnimationDone = new EventEmitter();
+ e.closeAnimationDone = new EventEmitter();
+ });
+ tree.ngAfterViewInit();
+ // inits cache on tree.ngAfterViewInit();
+ expect(nav.init_invisible_cache).toHaveBeenCalledTimes(1);
+ // init cache when tree nodes collection changes;
+ (tree.nodes as any).changes.emit();
+ expect(nav.init_invisible_cache).toHaveBeenCalledTimes(2);
+ emitNode.expandedChange.emit(true);
+ expect(nav.update_visible_cache).toHaveBeenCalledTimes(3);
+ emitNode.expandedChange.emit(false);
+ expect(nav.update_visible_cache).toHaveBeenCalledTimes(4);
+ nav.ngOnDestroy();
+ tree.ngOnDestroy();
+ });
+ });
+ });
+});
+
+
+
diff --git a/projects/igniteui-angular/src/lib/tree/tree-node/tree-node.component.html b/projects/igniteui-angular/src/lib/tree/tree-node/tree-node.component.html
new file mode 100644
index 00000000000..d4160a834c8
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-node/tree-node.component.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ expanded ? "keyboard_arrow_down" : "keyboard_arrow_right" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/igniteui-angular/src/lib/tree/tree-node/tree-node.component.ts b/projects/igniteui-angular/src/lib/tree/tree-node/tree-node.component.ts
new file mode 100644
index 00000000000..2705f004856
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-node/tree-node.component.ts
@@ -0,0 +1,695 @@
+import { AnimationBuilder } from '@angular/animations';
+import {
+ Component, OnInit,
+ OnDestroy, Input, Inject, ViewChild, TemplateRef, AfterViewInit, QueryList, ContentChildren, Optional, SkipSelf,
+ HostBinding,
+ ElementRef,
+ ChangeDetectorRef,
+ Output,
+ EventEmitter,
+ Directive,
+ HostListener
+} from '@angular/core';
+import { takeUntil } from 'rxjs/operators';
+import { ToggleAnimationPlayer, ToggleAnimationSettings } from '../../expansion-panel/toggle-animation-component';
+import {
+ IGX_TREE_COMPONENT, IgxTree, IgxTreeNode,
+ IGX_TREE_NODE_COMPONENT, ITreeNodeTogglingEventArgs, IgxTreeSelectionType
+} from '../common';
+import { IgxTreeSelectionService } from '../tree-selection.service';
+import { IgxTreeNavigationService } from '../tree-navigation.service';
+import { IgxTreeService } from '../tree.service';
+import { ITreeResourceStrings } from '../../core/i18n/tree-resources';
+import { CurrentResourceStrings } from '../../core/i18n/resources';
+import { DisplayDensity } from '../../core/displayDensity';
+
+// TODO: Implement aria functionality
+/**
+ * @hidden @internal
+ * Used for links (`a` tags) in the body of an `igx-tree-node`. Handles aria and event dispatch.
+ */
+@Directive({
+ selector: `[igxTreeNodeLink]`
+})
+export class IgxTreeNodeLinkDirective implements OnDestroy {
+
+ @HostBinding('attr.role')
+ public role = 'treeitem';
+
+ /**
+ * The node's parent. Should be used only when the link is defined
+ * in `` tag outside of its parent, as Angular DI will not properly provide a reference
+ *
+ * ```html
+ *
+ *
+ *
+ *
+ *
+ * ...
+ *
+ *
+ * {{ data.label }}
+ *
+ *
+ * ```
+ */
+ @Input('igxTreeNodeLink')
+ public set parentNode(val: any) {
+ if (val) {
+ this._parentNode = val;
+ (this._parentNode as any).addLinkChild(this);
+ }
+ }
+
+ public get parentNode(): any {
+ return this._parentNode;
+ }
+
+ /** A pointer to the parent node */
+ private get target(): IgxTreeNode {
+ return this.node || this.parentNode;
+ }
+
+ private _parentNode: IgxTreeNode = null;
+
+ constructor(@Optional() @Inject(IGX_TREE_NODE_COMPONENT)
+ private node: IgxTreeNode,
+ private navService: IgxTreeNavigationService,
+ public elementRef: ElementRef) {
+ }
+
+ /** @hidden @internal */
+ @HostBinding('attr.tabindex')
+ public get tabIndex(): number {
+ return this.navService.focusedNode === this.target ? (this.target?.disabled ? -1 : 0) : -1;
+ }
+
+ /**
+ * @hidden @internal
+ * Clear the node's focused state
+ */
+ @HostListener('blur')
+ public handleBlur() {
+ this.target.isFocused = false;
+ }
+
+ /**
+ * @hidden @internal
+ * Set the node as focused
+ */
+ @HostListener('focus')
+ public handleFocus() {
+ if (this.target && !this.target.disabled) {
+ if (this.navService.focusedNode !== this.target) {
+ this.navService.focusedNode = this.target;
+ }
+ this.target.isFocused = true;
+ }
+ }
+
+ public ngOnDestroy() {
+ this.target.removeLinkChild(this);
+ }
+}
+
+/**
+ *
+ * The tree node component represents a child node of the tree component or another tree node.
+ * Usage:
+ *
+ * ```html
+ *
+ * ...
+ *
+ * {{ data.FirstName }} {{ data.LastName }}
+ *
+ * ...
+ *
+ * ```
+ */
+@Component({
+ selector: 'igx-tree-node',
+ templateUrl: 'tree-node.component.html',
+ providers: [
+ { provide: IGX_TREE_NODE_COMPONENT, useExisting: IgxTreeNodeComponent }
+ ]
+})
+export class IgxTreeNodeComponent extends ToggleAnimationPlayer implements IgxTreeNode, OnInit, AfterViewInit, OnDestroy {
+ /**
+ * The data entry that the node is visualizing.
+ *
+ * @remarks
+ * Required for searching through nodes.
+ *
+ * @example
+ * ```html
+ *
+ * ...
+ *
+ * {{ data.FirstName }} {{ data.LastName }}
+ *
+ * ...
+ *
+ * ```
+ */
+ @Input()
+ public data: T;
+
+ /**
+ * To be used for load-on-demand scenarios in order to specify whether the node is loading data.
+ *
+ * @remarks
+ * Loading nodes do not render children.
+ */
+ @Input()
+ public loading = false;
+
+ // TO DO: return different tab index depending on anchor child
+ /** @hidden @internal */
+ public set tabIndex(val: number) {
+ this._tabIndex = val;
+ }
+
+ /** @hidden @internal */
+ public get tabIndex(): number {
+ if (this.disabled) {
+ return -1;
+ }
+ if (this._tabIndex === null) {
+ if (this.navService.focusedNode === null) {
+ return this.hasLinkChildren ? -1 : 0;
+ }
+ return -1;
+ }
+ return this.hasLinkChildren ? -1 : this._tabIndex;
+ }
+
+ /** @hidden @internal */
+ public get animationSettings(): ToggleAnimationSettings {
+ return this.tree.animationSettings;
+ }
+
+ /**
+ * Gets/Sets the resource strings.
+ *
+ * @remarks
+ * Uses EN resources by default.
+ */
+ @Input()
+ public set resourceStrings(value: ITreeResourceStrings) {
+ this._resourceStrings = Object.assign({}, this._resourceStrings, value);
+ }
+
+ /**
+ * An accessor that returns the resource strings.
+ */
+ public get resourceStrings(): ITreeResourceStrings {
+ if (!this._resourceStrings) {
+ this._resourceStrings = CurrentResourceStrings.TreeResStrings;
+ }
+ return this._resourceStrings;
+ }
+
+ /**
+ * Gets/Sets the active state of the node
+ *
+ * @param value: boolean
+ */
+ @Input()
+ public set active(value: boolean) {
+ if (value) {
+ this.navService.activeNode = this;
+ this.tree.activeNodeBindingChange.emit(this);
+ }
+ }
+
+ public get active(): boolean {
+ return this.navService.activeNode === this;
+ }
+
+ /**
+ * Emitted when the node's `selected` property changes.
+ *
+ * ```html
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const node: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * node.selectedChange.pipe(takeUntil(this.destroy$)).subscribe((e: boolean) => console.log("Node selection changed to ", e))
+ * ```
+ */
+ @Output()
+ public selectedChange = new EventEmitter();
+
+ /**
+ * Emitted when the node's `expanded` property changes.
+ *
+ * ```html
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const node: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * node.expandedChange.pipe(takeUntil(this.destroy$)).subscribe((e: boolean) => console.log("Node expansion state changed to ", e))
+ * ```
+ */
+ @Output()
+ public expandedChange = new EventEmitter();
+
+ /** @hidden @internal */
+ public get focused() {
+ return this.isFocused &&
+ this.navService.focusedNode === this;
+ }
+
+ /**
+ * Retrieves the full path to the node incuding itself
+ *
+ * ```typescript
+ * const node: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * const path: IgxTreeNode[] = node.path;
+ * ```
+ */
+ public get path(): IgxTreeNode[] {
+ return this.parentNode?.path ? [...this.parentNode.path, this] : [this];
+ }
+
+ // TODO: bind to disabled state when node is dragged
+ /**
+ * Gets/Sets the disabled state of the node
+ *
+ * @param value: boolean
+ */
+ @Input()
+ @HostBinding('class.igx-tree-node--disabled')
+ public get disabled(): boolean {
+ return this._disabled;
+ }
+
+ public set disabled(value: boolean) {
+ if (value !== this._disabled) {
+ this._disabled = value;
+ this.tree.disabledChange.emit(this);
+ }
+ }
+
+ /** @hidden @internal */
+ @HostBinding('class.igx-tree-node')
+ public cssClass = 'igx-tree-node';
+
+ /** @hidden @internal */
+ @HostBinding('attr.role')
+ public get role() {
+ return this.hasLinkChildren ? 'none' : 'treeitem';
+ };
+
+ /** @hidden @internal */
+ @ContentChildren(IgxTreeNodeLinkDirective, { read: ElementRef })
+ public linkChildren: QueryList;
+
+ /** @hidden @internal */
+ @ContentChildren(IGX_TREE_NODE_COMPONENT, { read: IGX_TREE_NODE_COMPONENT })
+ public _children: QueryList>;
+
+ /** @hidden @internal */
+ @ContentChildren(IGX_TREE_NODE_COMPONENT, { read: IGX_TREE_NODE_COMPONENT, descendants: true })
+ public allChildren: QueryList>;
+
+ /**
+ * Return the child nodes of the node (if any)
+ *
+ * @remark
+ * Returns `null` if node does not have children
+ *
+ * @example
+ * ```typescript
+ * const node: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * const children: IgxTreeNode[] = node.children;
+ * ```
+ */
+ public get children(): IgxTreeNode[] {
+ return this._children?.length ? this._children.toArray() : null;
+ }
+
+ // TODO: will be used in Drag and Drop implementation
+ /** @hidden @internal */
+ @ViewChild('ghostTemplate', { read: ElementRef })
+ public header: ElementRef;
+
+ @ViewChild('defaultIndicator', { read: TemplateRef, static: true })
+ private _defaultExpandIndicatorTemplate: TemplateRef;
+
+ @ViewChild('childrenContainer', { read: ElementRef })
+ private childrenContainer: ElementRef;
+
+ private get hasLinkChildren(): boolean {
+ return this.linkChildren?.length > 0 || this.registeredChildren?.length > 0;
+ }
+
+ /** @hidden @internal */
+ public get isCompact(): boolean {
+ return this.tree?.displayDensity === DisplayDensity.compact;
+ }
+
+ /** @hidden @internal */
+ public get isCosy(): boolean {
+ return this.tree?.displayDensity === DisplayDensity.cosy;
+ }
+
+ /** @hidden @internal */
+ public isFocused: boolean;
+
+ /** @hidden @internal */
+ public registeredChildren: IgxTreeNodeLinkDirective[] = [];
+
+ /** @hidden @internal */
+ private _resourceStrings = CurrentResourceStrings.TreeResStrings;
+
+ private _tabIndex = null;
+ private _disabled = false;
+
+ constructor(
+ @Inject(IGX_TREE_COMPONENT) public tree: IgxTree,
+ protected selectionService: IgxTreeSelectionService,
+ protected treeService: IgxTreeService,
+ protected navService: IgxTreeNavigationService,
+ protected cdr: ChangeDetectorRef,
+ protected builder: AnimationBuilder,
+ private element: ElementRef,
+ @Optional() @SkipSelf() @Inject(IGX_TREE_NODE_COMPONENT) public parentNode: IgxTreeNode
+ ) {
+ super(builder);
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public get showSelectors() {
+ return this.tree.selection !== IgxTreeSelectionType.None;
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public get indeterminate(): boolean {
+ return this.selectionService.isNodeIndeterminate(this);
+ }
+
+ /** The depth of the node, relative to the root
+ *
+ * ```html
+ *
+ * ...
+ *
+ * My level is {{ node.level }}
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const node: IgxTreeNode = this.tree.findNodes(data[12])[0];
+ * const level: number = node.level;
+ * ```
+ */
+ public get level(): number {
+ return this.parentNode ? this.parentNode.level + 1 : 0;
+ }
+
+ /** Get/set whether the node is selected. Supporst two-way binding.
+ *
+ * ```html
+ *
+ * ...
+ *
+ * {{ node.label }}
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const node: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * const selected = node.selected;
+ * node.selected = true;
+ * ```
+ */
+ @Input()
+ public get selected(): boolean {
+ return this.selectionService.isNodeSelected(this);
+ }
+
+ public set selected(val: boolean) {
+ if (!(this.tree?.nodes && this.tree.nodes.find((e) => e === this)) && val) {
+ this.tree.forceSelect.push(this);
+ return;
+ }
+ if (val && !this.selectionService.isNodeSelected(this)) {
+ this.selectionService.selectNodesWithNoEvent([this]);
+ }
+ if (!val && this.selectionService.isNodeSelected(this)) {
+ this.selectionService.deselectNodesWithNoEvent([this]);
+ }
+ }
+
+ /** Get/set whether the node is expanded
+ *
+ * ```html
+ *
+ * ...
+ *
+ * {{ node.label }}
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const node: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * const expanded = node.expanded;
+ * node.expanded = true;
+ * ```
+ */
+ @Input()
+ public get expanded() {
+ return this.treeService.isExpanded(this);
+ }
+
+ public set expanded(val: boolean) {
+ if (val) {
+ this.treeService.expand(this, false);
+ } else {
+ this.treeService.collapse(this);
+ }
+ }
+
+ /** @hidden @internal */
+ public get expandIndicatorTemplate(): TemplateRef {
+ return this.tree?.expandIndicator ? this.tree.expandIndicator : this._defaultExpandIndicatorTemplate;
+ }
+
+ /**
+ * The native DOM element representing the node. Could be null in certain environments.
+ *
+ * ```typescript
+ * // get the nativeElement of the second node
+ * const node: IgxTreeNode = this.tree.nodes.first();
+ * const nodeElement: HTMLElement = node.nativeElement;
+ * ```
+ */
+ /** @hidden @internal */
+ public get nativeElement() {
+ return this.element.nativeElement;
+ }
+
+ /** @hidden @internal */
+ public ngOnInit() {
+ this.openAnimationDone.pipe(takeUntil(this.destroy$)).subscribe(
+ () => {
+ this.tree.nodeExpanded.emit({ owner: this.tree, node: this });
+ }
+ );
+ this.closeAnimationDone.pipe(takeUntil(this.destroy$)).subscribe(() => {
+ this.tree.nodeCollapsed.emit({ owner: this.tree, node: this });
+ this.treeService.collapse(this);
+ this.cdr.markForCheck();
+ });
+ }
+
+ /** @hidden @internal */
+ public ngAfterViewInit() { }
+
+ /**
+ * @hidden @internal
+ * Sets the focus to the node's child, if present
+ * Sets the node as the tree service's focusedNode
+ * Marks the node as the current active element
+ */
+ public handleFocus(): void {
+ if (this.disabled) {
+ return;
+ }
+ if (this.navService.focusedNode !== this) {
+ this.navService.focusedNode = this;
+ }
+ this.isFocused = true;
+ if (this.linkChildren?.length) {
+ this.linkChildren.first.nativeElement.focus();
+ return;
+ }
+ if (this.registeredChildren.length) {
+ this.registeredChildren[0].elementRef.nativeElement.focus();
+ return;
+ }
+ }
+
+ /**
+ * @hidden @internal
+ * Clear the node's focused status
+ */
+ public clearFocus(): void {
+ this.isFocused = false;
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public onSelectorClick(event) {
+ // event.stopPropagation();
+ event.preventDefault();
+ // this.navService.handleFocusedAndActiveNode(this);
+ if (event.shiftKey) {
+ this.selectionService.selectMultipleNodes(this, event);
+ return;
+ }
+ if (this.selected) {
+ this.selectionService.deselectNode(this, event);
+ } else {
+ this.selectionService.selectNode(this, event);
+ }
+ }
+
+ /**
+ * Toggles the node expansion state, triggering animation
+ *
+ * ```html
+ *
+ * My Node
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const myNode: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * myNode.toggle();
+ * ```
+ */
+ public toggle() {
+ if (this.expanded) {
+ this.collapse();
+ } else {
+ this.expand();
+ }
+ }
+
+ /** @hidden @internal */
+ public indicatorClick() {
+ this.toggle();
+ this.navService.setFocusedAndActiveNode(this);
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public onPointerDown(event) {
+ event.stopPropagation();
+ this.navService.setFocusedAndActiveNode(this);
+ }
+
+ public ngOnDestroy() {
+ super.ngOnDestroy();
+ this.selectionService.ensureStateOnNodeDelete(this);
+ }
+
+ /**
+ * Expands the node, triggering animation
+ *
+ * ```html
+ *
+ * My Node
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const myNode: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * myNode.expand();
+ * ```
+ */
+ public expand() {
+ const args: ITreeNodeTogglingEventArgs = {
+ owner: this.tree,
+ node: this,
+ cancel: false
+
+ };
+ this.tree.nodeExpanding.emit(args);
+ if (!args.cancel) {
+ this.treeService.expand(this, true);
+ this.cdr.detectChanges();
+ this.playOpenAnimation(
+ this.childrenContainer
+ );
+ }
+ }
+
+ /**
+ * Collapses the node, triggering animation
+ *
+ * ```html
+ *
+ * My Node
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const myNode: IgxTreeNode = this.tree.findNodes(data[0])[0];
+ * myNode.collapse();
+ * ```
+ */
+ public collapse() {
+ const args: ITreeNodeTogglingEventArgs = {
+ owner: this.tree,
+ node: this,
+ cancel: false
+
+ };
+ this.tree.nodeCollapsing.emit(args);
+ if (!args.cancel) {
+ this.treeService.collapsing(this);
+ this.playCloseAnimation(
+ this.childrenContainer
+ );
+ }
+ }
+
+ /** @hidden @internal */
+ public addLinkChild(link: IgxTreeNodeLinkDirective) {
+ this._tabIndex = -1;
+ this.registeredChildren.push(link);
+ };
+
+ /** @hidden @internal */
+ public removeLinkChild(link: IgxTreeNodeLinkDirective) {
+ const index = this.registeredChildren.indexOf(link);
+ if (index !== -1) {
+ this.registeredChildren.splice(index, 1);
+ }
+ }
+}
diff --git a/projects/igniteui-angular/src/lib/tree/tree-samples.spec.ts b/projects/igniteui-angular/src/lib/tree/tree-samples.spec.ts
new file mode 100644
index 00000000000..cb56b7f144a
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-samples.spec.ts
@@ -0,0 +1,120 @@
+import { Component, ViewChild, ChangeDetectorRef } from '@angular/core';
+import { IgxTreeComponent } from './public_api';
+import { HIERARCHICAL_SAMPLE_DATA } from 'src/app/shared/sample-data';
+
+@Component({
+ template: `
+
+
+ {{ node.CompanyName }}
+
+ {{ child.CompanyName }}
+
+ {{ leafchild.CompanyName }}
+
+
+
+
+ `
+})
+export class IgxTreeSimpleComponent {
+ @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent;
+ public data = HIERARCHICAL_SAMPLE_DATA;
+}
+
+@Component({
+ template: `
+
+
+ {{ node.CompanyName }}
+
+ {{ child.CompanyName }}
+
+ {{ leafchild.CompanyName }}
+
+
+
+
+ `
+})
+export class IgxTreeSelectionSampleComponent {
+ @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent;
+ public data;
+ constructor(public cdr: ChangeDetectorRef) {
+ this.data = HIERARCHICAL_SAMPLE_DATA;
+ this.mapData(this.data);
+ }
+ private mapData(data: any[]) {
+ data.forEach(x => {
+ x.selected = false;
+ if (x.hasOwnProperty('ChildCompanies') && x.ChildCompanies.length) {
+ this.mapData(x.ChildCompanies);
+ }
+ });
+ }
+}
+
+@Component({
+ template: `
+
+
+ {{ node.CompanyName }}
+ Disable Node Level 1
+
+ {{ child.CompanyName }}
+
+ {{ leafchild.CompanyName }}
+
+ Disable Node Level 2
+
+
+ Disable Node Level 0
+
+ Link to Infragistics
+
+ Link to Infragistics
+
+
+ Link to Infragistics
+
+
+
+
+
+
+
+
+
+
+
+
+ Link to Infragistics
+
+
+ `
+})
+export class IgxTreeNavigationComponent {
+ @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent;
+ public data = HIERARCHICAL_SAMPLE_DATA;
+ public showNodesWithDirective = false;
+ public isDisabled = false;
+}
+@Component({
+ template: `
+
+
+ {{ node.CompanyName }}
+
+ {{ child.CompanyName }}
+
+ {{ leafchild.CompanyName }}
+
+
+
+
+ `
+})
+export class IgxTreeScrollComponent {
+ @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent;
+ public data = HIERARCHICAL_SAMPLE_DATA;
+}
diff --git a/projects/igniteui-angular/src/lib/tree/tree-selection.service.spec.ts b/projects/igniteui-angular/src/lib/tree/tree-selection.service.spec.ts
new file mode 100644
index 00000000000..c7c0d988755
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-selection.service.spec.ts
@@ -0,0 +1,655 @@
+import { EventEmitter } from '@angular/core';
+import { configureTestSuite } from '../test-utils/configure-suite';
+import { IgxTree, IgxTreeNode, IgxTreeSelectionType, ITreeNodeSelectionEvent } from './common';
+import { TreeTestFunctions } from './tree-functions.spec';
+import { IgxTreeNodeComponent } from './tree-node/tree-node.component';
+import { IgxTreeSelectionService } from './tree-selection.service';
+
+describe('IgxTreeSelectionService - Unit Tests #treeView', () => {
+ configureTestSuite();
+ let selectionService: IgxTreeSelectionService;
+ let mockEmitter: EventEmitter;
+ let mockTree: IgxTree;
+ let mockNodesLevel1: IgxTreeNodeComponent[];
+ let mockNodesLevel2_1: IgxTreeNodeComponent[];
+ let mockNodesLevel2_2: IgxTreeNodeComponent[];
+ let mockNodesLevel3_1: IgxTreeNodeComponent[];
+ let mockNodesLevel3_2: IgxTreeNodeComponent[];
+ let allNodes: IgxTreeNodeComponent[];
+ const mockQuery1: any = {};
+ const mockQuery2: any = {};
+ const mockQuery3: any = {};
+ const mockQuery4: any = {};
+ const mockQuery5: any = {};
+ const mockQuery6: any = {};
+
+ beforeEach(() => {
+ selectionService = new IgxTreeSelectionService();
+ mockNodesLevel1 = TreeTestFunctions.createNodeSpies(0, 3, null, [mockQuery2, mockQuery3], [mockQuery6, mockQuery3]);
+ mockNodesLevel2_1 = TreeTestFunctions.createNodeSpies(1, 2, mockNodesLevel1[0], [mockQuery4, mockQuery5], [mockQuery4, mockQuery5]);
+ mockNodesLevel2_2 = TreeTestFunctions.createNodeSpies(1, 1, mockNodesLevel1[1], null);
+ mockNodesLevel3_1 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[0], null);
+ mockNodesLevel3_2 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[1], null);
+ allNodes = [
+ mockNodesLevel1[0],
+ mockNodesLevel2_1[0],
+ ...mockNodesLevel3_1,
+ mockNodesLevel2_1[1],
+ ...mockNodesLevel3_2,
+ mockNodesLevel1[1],
+ ...mockNodesLevel2_2,
+ mockNodesLevel1[2]
+ ];
+
+ Object.assign(mockQuery1, TreeTestFunctions.createQueryListSpy(allNodes));
+ Object.assign(mockQuery2, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_1));
+ Object.assign(mockQuery3, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_2));
+ Object.assign(mockQuery4, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_1));
+ Object.assign(mockQuery5, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_2));
+ Object.assign(mockQuery6, TreeTestFunctions.createQueryListSpy([
+ mockNodesLevel2_1[0],
+ ...mockNodesLevel3_1,
+ mockNodesLevel2_1[1],
+ ...mockNodesLevel3_2
+ ]));
+ });
+
+ describe('IgxTreeSelectionService - BiState & None', () => {
+ beforeEach(() => {
+ mockEmitter = jasmine.createSpyObj('emitter', ['emit']);
+ mockTree = jasmine.createSpyObj('tree', [''],
+ { selection: IgxTreeSelectionType.BiState, nodeSelection: mockEmitter, nodes: mockQuery1 });
+ selectionService.register(mockTree);
+ });
+
+ it('Should properly register the specified tree', () => {
+ selectionService = new IgxTreeSelectionService();
+
+ expect((selectionService as any).tree).toBeFalsy();
+
+ selectionService.register(mockTree);
+ expect((selectionService as any).tree).toEqual(mockTree);
+ });
+
+ it('Should return proper value when isNodeSelected is called', () => {
+ const selectionSet: Set> = (selectionService as any).nodeSelection;
+
+ expect(selectionSet.size).toBe(0);
+
+ spyOn(selectionSet, 'clear').and.callThrough();
+
+ const mockNode1 = TreeTestFunctions.createNodeSpy();
+ const mockNode2 = TreeTestFunctions.createNodeSpy();
+ expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy();
+ expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy();
+
+ selectionSet.add(mockNode1);
+
+ expect(selectionService.isNodeSelected(mockNode1)).toBeTruthy();
+ expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy();
+ expect(selectionSet.size).toBe(1);
+
+ selectionService.clearNodesSelection();
+
+ expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy();
+ expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy();
+ expect(selectionSet.clear).toHaveBeenCalled();
+ expect(selectionSet.size).toBe(0);
+ });
+
+ it('Should handle selection based on tree.selection', () => {
+ const mockSelectedChangeEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']);
+ const mockNode = TreeTestFunctions.createNodeSpy({ selectedChange: mockSelectedChangeEmitter });
+
+ // None
+ (Object.getOwnPropertyDescriptor(mockTree, 'selection').get as jasmine.Spy).and.returnValue(IgxTreeSelectionType.None);
+ selectionService.selectNode(mockNode);
+ expect(selectionService.isNodeSelected(mockNode)).toBeFalsy();
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ expect(mockNode.selectedChange.emit).not.toHaveBeenCalled();
+
+ // BiState
+ (Object.getOwnPropertyDescriptor(mockTree, 'selection').get as jasmine.Spy)
+ .and.returnValue(IgxTreeSelectionType.BiState);
+ let expected: ITreeNodeSelectionEvent = {
+ oldSelection: [], newSelection: [mockNode],
+ added: [mockNode], removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+
+ selectionService.selectNode(mockNode);
+
+ expect(selectionService.isNodeSelected(mockNode)).toBeTruthy();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ expect(mockNode.selectedChange.emit).toHaveBeenCalledTimes(1);
+ expect(mockNode.selectedChange.emit).toHaveBeenCalledWith(true);
+
+ // Cascading
+ selectionService.deselectNode(mockNode);
+
+ (Object.getOwnPropertyDescriptor(mockTree, 'selection').get as jasmine.Spy)
+ .and.returnValue(IgxTreeSelectionType.Cascading);
+ selectionService.selectNode(allNodes[1]);
+
+ expected = {
+ oldSelection: [], newSelection: [allNodes[1], allNodes[2], allNodes[3]],
+ added: [allNodes[1], allNodes[2], allNodes[3]], removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+
+ expect(selectionService.isNodeSelected(allNodes[1])).toBeTruthy();
+ expect(selectionService.isNodeSelected(allNodes[2])).toBeTruthy();
+ expect(selectionService.isNodeSelected(allNodes[3])).toBeTruthy();
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(3);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ for (let i = 1; i < 4; i++) {
+ expect(allNodes[i].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[i].selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ });
+
+ it('Should deselect nodes', () => {
+ const mockSelectedChangeEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']);
+ const mockNode1 = TreeTestFunctions.createNodeSpy({ selectedChange: mockSelectedChangeEmitter });
+ const mockNode2 = TreeTestFunctions.createNodeSpy({ selectedChange: mockSelectedChangeEmitter });
+
+ selectionService.deselectNode(mockNode1);
+
+ expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy();
+ expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy();
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ expect(mockNode1.selectedChange.emit).not.toHaveBeenCalled();
+
+ // mark a node as selected
+ selectionService.selectNode(mockNode1);
+
+ expect(selectionService.isNodeSelected(mockNode1)).toBeTruthy();
+ expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1);
+ expect(mockNode1.selectedChange.emit).toHaveBeenCalledTimes(1);
+ expect(mockNode1.selectedChange.emit).toHaveBeenCalledWith(true);
+
+ // deselect node
+ const expected: ITreeNodeSelectionEvent = {
+ newSelection: [], oldSelection: [mockNode1],
+ removed: [mockNode1], added: [], event: undefined, cancel: false, owner: mockTree
+ };
+ selectionService.deselectNode(mockNode1);
+
+ expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy();
+ expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ expect(mockNode1.selectedChange.emit).toHaveBeenCalledTimes(2);
+ expect(mockNode1.selectedChange.emit).toHaveBeenCalledWith(false);
+ });
+
+ it('Should be able to deselect all nodes', () => {
+ selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3));
+ for (const node of allNodes.slice(0, 3)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+
+ selectionService.deselectNodesWithNoEvent();
+
+ for (const node of allNodes.slice(0, 3)) {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(false);
+ }
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ });
+
+ it('Should be able to deselect range of nodes', () => {
+ selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3));
+
+ for (const node of allNodes.slice(0, 3)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+
+ selectionService.deselectNodesWithNoEvent([allNodes[0], allNodes[2]]);
+
+ expect(selectionService.isNodeSelected(allNodes[0])).toBeFalsy();
+ expect(selectionService.isNodeSelected(allNodes[2])).toBeFalsy();
+ expect(allNodes[0].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[0].selectedChange.emit).toHaveBeenCalledWith(false);
+ expect(allNodes[2].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[2].selectedChange.emit).toHaveBeenCalledWith(false);
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ });
+
+ it('Should be able to select multiple nodes', () => {
+ selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3));
+
+ for (const node of allNodes.slice(0, 3)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeSelected(allNodes[3])).toBeFalsy();
+
+ selectionService.selectNodesWithNoEvent([allNodes[3]]);
+ for (const node of allNodes.slice(0, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ }
+ expect(allNodes[3].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[3].selectedChange.emit).toHaveBeenCalledWith(true);
+
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ });
+
+ it('Should be able to clear selection when adding multiple nodes', () => {
+ selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3));
+
+ for (const node of allNodes.slice(0, 3)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeSelected(allNodes[3])).toBeFalsy();
+
+ selectionService.selectNodesWithNoEvent(allNodes.slice(1, 4), true);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeSelected(allNodes[0])).toBeFalsy();
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ });
+
+ it('Should add newly selected nodes to the existing selection', () => {
+ selectionService.selectNode(mockTree.nodes.first);
+
+ let expected: ITreeNodeSelectionEvent = {
+ oldSelection: [], newSelection: [mockQuery1.first],
+ added: [mockQuery1.first], removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+
+ expect(selectionService.isNodeSelected(allNodes[0])).toBeTruthy();
+ expect(mockTree.nodes.first.selectedChange.emit).toHaveBeenCalled();
+ expect(mockTree.nodes.first.selectedChange.emit).toHaveBeenCalledWith(true);
+
+ for (let i = 1; i < allNodes.length; i++) {
+ expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy();
+ }
+
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1);
+
+ expected = {
+ oldSelection: [allNodes[0]], newSelection: [allNodes[0], allNodes[1]],
+ added: [allNodes[1]], removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+
+ selectionService.selectNode(mockTree.nodes.toArray()[1]);
+
+ expect(mockTree.nodes.toArray()[1].selectedChange.emit).toHaveBeenCalled();
+ expect(mockTree.nodes.toArray()[1].selectedChange.emit).toHaveBeenCalledWith(true);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2);
+ expect(selectionService.isNodeSelected(allNodes[0])).toBeTruthy();
+ expect(selectionService.isNodeSelected(allNodes[1])).toBeTruthy();
+ });
+
+ it('Should be able to select a range of nodes', () => {
+ selectionService.selectNode(allNodes[3]);
+
+ // only third node is selected
+ expect(selectionService.isNodeSelected(allNodes[3])).toBeTruthy();
+ expect(allNodes[3].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[3].selectedChange.emit).toHaveBeenCalledWith(true);
+ for (let i = 0; i < allNodes.length; i++) {
+ if (i !== 3) {
+ expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy();
+ }
+ }
+
+ // select all nodes from third to eighth
+ selectionService.selectMultipleNodes(allNodes[8]);
+
+ allNodes.forEach((node, index) => {
+ if (index >= 3 && index <= 8) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ } else {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ }
+ });
+
+ const expected: ITreeNodeSelectionEvent = {
+ oldSelection: [allNodes[3]], newSelection: allNodes.slice(3, 9),
+ added: allNodes.slice(4, 9), removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ });
+
+ it('Should be able to select a range of nodes in reverse order', () => {
+ selectionService.selectNode(allNodes[8]);
+
+ // only eighth node is selected
+ expect(selectionService.isNodeSelected(allNodes[8])).toBeTruthy();
+ expect(allNodes[8].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[8].selectedChange.emit).toHaveBeenCalledWith(true);
+ for (let i = 0; i < allNodes.length; i++) {
+ if (i !== 8) {
+ expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy();
+ }
+ }
+
+ // select all nodes from eighth to second
+ selectionService.selectMultipleNodes(allNodes[2]);
+
+ allNodes.forEach((node, index) => {
+ if (index >= 2 && index <= 8) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ } else {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ }
+ });
+
+ const expected: ITreeNodeSelectionEvent = {
+ oldSelection: [allNodes[8]], newSelection: [allNodes[8], ...allNodes.slice(2, 8)],
+ added: allNodes.slice(2, 8), removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ });
+ });
+
+ describe('IgxTreeSelectionService - Cascading', () => {
+ beforeEach(() => {
+ mockEmitter = jasmine.createSpyObj('emitter', ['emit']);
+ mockTree = jasmine.createSpyObj('tree', [''],
+ { selection: IgxTreeSelectionType.Cascading, nodeSelection: mockEmitter, nodes: mockQuery1 });
+ selectionService.register(mockTree);
+ });
+
+ it('Should deselect nodes', () => {
+ selectionService.deselectNode(allNodes[1]);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ expect(node.selectedChange.emit).not.toHaveBeenCalled();
+ }
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+
+ // mark a node as selected
+ selectionService.selectNode(allNodes[1]);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(allNodes[0].selectedChange.emit).not.toHaveBeenCalled();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1);
+
+ const expected: ITreeNodeSelectionEvent = {
+ newSelection: [], oldSelection: [allNodes[1], allNodes[2], allNodes[3]],
+ removed: [allNodes[1], allNodes[2], allNodes[3]], added: [], event: undefined, cancel: false, owner: mockTree
+ };
+ // deselect node
+ selectionService.deselectNode(allNodes[1]);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(false);
+ }
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeFalse();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ });
+
+ it('Should be able to deselect range of nodes', () => {
+ selectionService.selectNodesWithNoEvent([allNodes[1], allNodes[4]]);
+
+ for (const node of allNodes.slice(0, 7)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+
+ selectionService.deselectNodesWithNoEvent([allNodes[1], allNodes[5]]);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(false);
+ }
+ expect(selectionService.isNodeSelected(allNodes[5])).toBeFalsy();
+ expect(selectionService.isNodeSelected(allNodes[6])).toBeTruthy();
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(selectionService.isNodeIndeterminate(allNodes[4])).toBeTruthy();
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ });
+
+ it('Should be able to select multiple nodes', () => {
+ selectionService.selectNodesWithNoEvent([allNodes[1], allNodes[8]]);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeSelected(allNodes[7])).toBeTruthy();
+ expect(selectionService.isNodeSelected(allNodes[8])).toBeTruthy();
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ });
+
+ it('Should be able to clear selection when adding multiple nodes', () => {
+ selectionService.selectNodesWithNoEvent([allNodes[1]], true);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+
+ selectionService.selectNodesWithNoEvent([allNodes[3], allNodes[4]], true);
+
+ for (const node of allNodes.slice(3, 7)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(selectionService.isNodeIndeterminate(allNodes[1])).toBeTruthy();
+ expect(selectionService.isNodeSelected(allNodes[2])).toBeFalsy();
+ expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled();
+ });
+
+ it('Should add newly selected nodes to the existing selection', () => {
+ selectionService.selectNode(allNodes[1]);
+
+ let expected: ITreeNodeSelectionEvent = {
+ oldSelection: [], newSelection: allNodes.slice(1, 4),
+ added: allNodes.slice(1, 4), removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ for (let i = 4; i < allNodes.length; i++) {
+ expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy();
+ }
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1);
+
+ expected = {
+ oldSelection: allNodes.slice(1, 4), newSelection: [allNodes[1], allNodes[2], allNodes[3], allNodes[5]],
+ added: [allNodes[5]], removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+
+ selectionService.selectNode(allNodes[5]);
+
+ for (const node of allNodes.slice(1, 4)) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ expect(selectionService.isNodeSelected(allNodes[5])).toBeTruthy();
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2);
+ });
+
+ it('Should be able to select a range of nodes', () => {
+ selectionService.selectNode(allNodes[3]);
+ expect(selectionService.isNodeSelected(allNodes[3])).toBeTruthy();
+
+ // select all nodes from first to eighth
+ selectionService.selectMultipleNodes(allNodes[8]);
+
+ allNodes.forEach((node, index) => {
+ if (index >= 4 && index <= 8) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ } else if (index === 3) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ } else {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ }
+ });
+
+ const expected: ITreeNodeSelectionEvent = {
+ oldSelection: [allNodes[3]], newSelection: allNodes.slice(3, 9),
+ added: allNodes.slice(4, 9),
+ removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy();
+ expect(selectionService.isNodeIndeterminate(allNodes[1])).toBeTruthy();
+ });
+
+ it('Should be able to select a range of nodes in reverse order', () => {
+ selectionService.selectNode(allNodes[8]);
+
+ // only seventh and eighth node are selected
+ expect(selectionService.isNodeSelected(allNodes[7])).toBeTruthy();
+ expect(selectionService.isNodeSelected(allNodes[8])).toBeTruthy();
+ expect(allNodes[7].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[7].selectedChange.emit).toHaveBeenCalledWith(true);
+ expect(allNodes[8].selectedChange.emit).toHaveBeenCalled();
+ expect(allNodes[8].selectedChange.emit).toHaveBeenCalledWith(true);
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1);
+
+ for (let i = 0; i < allNodes.length; i++) {
+ if (i !== 7 && i !== 8) {
+ expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy();
+ }
+ }
+
+ // select all nodes from eight to second
+ selectionService.selectMultipleNodes(allNodes[2]);
+
+ allNodes.forEach((node, index) => {
+ if (index <= 8) {
+ expect(selectionService.isNodeSelected(node)).toBeTruthy();
+ if (index < 7) {
+ expect(node.selectedChange.emit).toHaveBeenCalled();
+ expect(node.selectedChange.emit).toHaveBeenCalledWith(true);
+ }
+ } else {
+ expect(selectionService.isNodeSelected(node)).toBeFalsy();
+ }
+ });
+
+ const expected: ITreeNodeSelectionEvent = {
+ oldSelection: [allNodes[8], allNodes[7]],
+ newSelection: [allNodes[8], allNodes[7], ...allNodes.slice(2, 7), allNodes[1], allNodes[0]],
+ added: [...allNodes.slice(2, 7), allNodes[1], allNodes[0]], removed: [], event: undefined, cancel: false, owner: mockTree
+ };
+ expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected);
+ });
+
+ it('Should ensure correct state after a node entry is destroyed', () => {
+ // instant frames go 'BRRRR'
+ spyOn(window, 'requestAnimationFrame').and.callFake((callback: any) => callback());
+ const deselectSpy = spyOn(selectionService, 'deselectNodesWithNoEvent');
+ const selectSpy = spyOn(selectionService, 'selectNodesWithNoEvent');
+ const tree = {
+ selection: IgxTreeSelectionType.None
+ } as any;
+ const selectedNodeSpy = spyOn(selectionService, 'isNodeSelected').and.returnValue(false);
+ const mockNode = {
+ selected: false
+ } as any;
+ selectionService.register(tree);
+
+ selectionService.ensureStateOnNodeDelete(mockNode);
+ expect(deselectSpy).not.toHaveBeenCalled();
+ expect(selectSpy).not.toHaveBeenCalled();
+ expect(selectedNodeSpy).not.toHaveBeenCalled();
+ tree.selection = IgxTreeSelectionType.BiState;
+
+ selectionService.ensureStateOnNodeDelete(mockNode);
+ expect(deselectSpy).not.toHaveBeenCalled();
+ expect(selectSpy).not.toHaveBeenCalled();
+ expect(selectedNodeSpy).not.toHaveBeenCalled();
+ tree.selection = IgxTreeSelectionType.Cascading;
+ selectedNodeSpy.and.returnValue(true);
+
+ selectionService.ensureStateOnNodeDelete(mockNode);
+ expect(selectedNodeSpy).toHaveBeenCalledTimes(1);
+ expect(selectedNodeSpy).toHaveBeenCalledWith(mockNode);
+ expect(deselectSpy).toHaveBeenCalledTimes(1);
+ expect(deselectSpy).toHaveBeenCalledWith([mockNode], false);
+ expect(selectSpy).not.toHaveBeenCalled();
+ mockNode.parentNode = false;
+ selectedNodeSpy.and.returnValue(false);
+
+ selectionService.ensureStateOnNodeDelete(mockNode);
+ expect(selectedNodeSpy).toHaveBeenCalledTimes(2);
+ expect(deselectSpy).toHaveBeenCalledTimes(1);
+ expect(selectSpy).not.toHaveBeenCalled();
+ const childrenSpy = jasmine.createSpyObj('creep', ['find']);
+ childrenSpy.find.and.returnValue(null);
+ mockNode.parentNode = {
+ allChildren: childrenSpy
+ };
+
+ selectionService.ensureStateOnNodeDelete(mockNode);
+ expect(selectedNodeSpy).toHaveBeenCalledTimes(3);
+ expect(deselectSpy).toHaveBeenCalledTimes(1);
+ expect(selectSpy).not.toHaveBeenCalled();
+ const mockChild = {
+ selected: true
+ } as any;
+ childrenSpy.find.and.returnValue(mockChild);
+
+ selectionService.ensureStateOnNodeDelete(mockNode);
+ expect(selectedNodeSpy).toHaveBeenCalledTimes(4);
+ expect(deselectSpy).toHaveBeenCalledTimes(1);
+ expect(selectSpy).toHaveBeenCalledTimes(1);
+ expect(selectSpy).toHaveBeenCalledWith([mockChild], false, false);
+
+ mockChild.selected = false;
+ selectionService.ensureStateOnNodeDelete(mockNode);
+ expect(selectedNodeSpy).toHaveBeenCalledTimes(5);
+ expect(deselectSpy).toHaveBeenCalledTimes(2);
+ expect(deselectSpy).toHaveBeenCalledWith([mockChild], false);
+ expect(selectSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/projects/igniteui-angular/src/lib/tree/tree-selection.service.ts b/projects/igniteui-angular/src/lib/tree/tree-selection.service.ts
new file mode 100644
index 00000000000..1c9bbc748c0
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-selection.service.ts
@@ -0,0 +1,362 @@
+import { Injectable } from '@angular/core';
+import { IgxTree, IgxTreeNode, IgxTreeSelectionType, ITreeNodeSelectionEvent } from './common';
+
+/** A collection containing the nodes affected in the selection as well as their direct parents */
+interface CascadeSelectionNodeCollection {
+ nodes: Set>;
+ parents: Set>;
+};
+
+/** @hidden @internal */
+@Injectable()
+export class IgxTreeSelectionService {
+ private tree: IgxTree;
+ private nodeSelection: Set> = new Set>();
+ private indeterminateNodes: Set> = new Set>();
+
+ private nodesToBeSelected: Set>;
+ private nodesToBeIndeterminate: Set>;
+
+ public register(tree: IgxTree) {
+ this.tree = tree;
+ }
+
+ /** Select range from last selected node to the current specified node. */
+ public selectMultipleNodes(node: IgxTreeNode, event?: Event): void {
+ if (!this.nodeSelection.size) {
+ this.selectNode(node);
+ return;
+ }
+ const lastSelectedNodeIndex = this.tree.nodes.toArray().indexOf(this.getSelectedNodes()[this.nodeSelection.size - 1]);
+ const currentNodeIndex = this.tree.nodes.toArray().indexOf(node);
+ const nodes = this.tree.nodes.toArray().slice(Math.min(currentNodeIndex, lastSelectedNodeIndex),
+ Math.max(currentNodeIndex, lastSelectedNodeIndex) + 1);
+
+ const added = nodes.filter(_node => !this.isNodeSelected(_node));
+ const newSelection = this.getSelectedNodes().concat(added);
+ this.emitNodeSelectionEvent(newSelection, added, [], event);
+ }
+
+ /** Select the specified node and emit event. */
+ public selectNode(node: IgxTreeNode, event?: Event): void {
+ if (this.tree.selection === IgxTreeSelectionType.None) {
+ return;
+ }
+ this.emitNodeSelectionEvent([...this.getSelectedNodes(), node], [node], [], event);
+ }
+
+ /** Deselect the specified node and emit event. */
+ public deselectNode(node: IgxTreeNode, event?: Event): void {
+ const newSelection = this.getSelectedNodes().filter(r => r !== node);
+ this.emitNodeSelectionEvent(newSelection, [], [node], event);
+ }
+
+ /** Clears node selection */
+ public clearNodesSelection(): void {
+ this.nodeSelection.clear();
+ this.indeterminateNodes.clear();
+ }
+
+ public isNodeSelected(node: IgxTreeNode): boolean {
+ return this.nodeSelection.has(node);
+ }
+
+ public isNodeIndeterminate(node: IgxTreeNode): boolean {
+ return this.indeterminateNodes.has(node);
+ }
+
+ /** Select specified nodes. No event is emitted. */
+ public selectNodesWithNoEvent(nodes: IgxTreeNode[], clearPrevSelection = false, shouldEmit = true): void {
+ if (this.tree && this.tree.selection === IgxTreeSelectionType.Cascading) {
+ this.cascadeSelectNodesWithNoEvent(nodes, clearPrevSelection);
+ return;
+ }
+
+ const oldSelection = this.getSelectedNodes();
+
+ if (clearPrevSelection) {
+ this.nodeSelection.clear();
+ }
+ nodes.forEach(node => this.nodeSelection.add(node));
+
+ if (shouldEmit) {
+ this.emitSelectedChangeEvent(oldSelection);
+ }
+ }
+
+ /** Deselect specified nodes. No event is emitted. */
+ public deselectNodesWithNoEvent(nodes?: IgxTreeNode[], shouldEmit = true): void {
+ const oldSelection = this.getSelectedNodes();
+
+ if (!nodes) {
+ this.nodeSelection.clear();
+ } else if (this.tree && this.tree.selection === IgxTreeSelectionType.Cascading) {
+ this.cascadeDeselectNodesWithNoEvent(nodes);
+ } else {
+ nodes.forEach(node => this.nodeSelection.delete(node));
+ }
+
+ if (shouldEmit) {
+ this.emitSelectedChangeEvent(oldSelection);
+ }
+ }
+
+ /** Called on `node.ngOnDestroy` to ensure state is correct after node is removed */
+ public ensureStateOnNodeDelete(node: IgxTreeNode): void {
+ if (this.tree?.selection !== IgxTreeSelectionType.Cascading) {
+ return;
+ }
+ requestAnimationFrame(() => {
+ if (this.isNodeSelected(node)) {
+ // node is destroyed, do not emit event
+ this.deselectNodesWithNoEvent([node], false);
+ } else {
+ if (!node.parentNode) {
+ return;
+ }
+ const assitantLeafNode = node.parentNode?.allChildren.find(e => !e._children?.length);
+ if (!assitantLeafNode) {
+ return;
+ }
+ this.retriggerNodeState(assitantLeafNode);
+ }
+ });
+ }
+
+ /** Retriggers a node's selection state */
+ private retriggerNodeState(node: IgxTreeNode): void {
+ if (node.selected) {
+ this.nodeSelection.delete(node);
+ this.selectNodesWithNoEvent([node], false, false);
+ } else {
+ this.nodeSelection.add(node);
+ this.deselectNodesWithNoEvent([node], false);
+ }
+ }
+
+ /** Returns array of the selected nodes. */
+ private getSelectedNodes(): IgxTreeNode[] {
+ return this.nodeSelection.size ? Array.from(this.nodeSelection) : [];
+ }
+
+ /** Returns array of the nodes in indeterminate state. */
+ private getIndeterminateNodes(): IgxTreeNode[] {
+ return this.indeterminateNodes.size ? Array.from(this.indeterminateNodes) : [];
+ }
+
+ private emitNodeSelectionEvent(
+ newSelection: IgxTreeNode[], added: IgxTreeNode[], removed: IgxTreeNode[], event: Event
+ ): boolean {
+ if (this.tree.selection === IgxTreeSelectionType.Cascading) {
+ this.emitCascadeNodeSelectionEvent(newSelection, added, removed, event);
+ return;
+ }
+ const currSelection = this.getSelectedNodes();
+ if (this.areEqualCollections(currSelection, newSelection)) {
+ return;
+ }
+
+ const args: ITreeNodeSelectionEvent = {
+ oldSelection: currSelection, newSelection,
+ added, removed, event, cancel: false, owner: this.tree
+ };
+ this.tree.nodeSelection.emit(args);
+ if (args.cancel) {
+ return;
+ }
+ this.selectNodesWithNoEvent(args.newSelection, true);
+ }
+
+ private areEqualCollections(first: IgxTreeNode[], second: IgxTreeNode[]): boolean {
+ return first.length === second.length && new Set(first.concat(second)).size === first.length;
+ }
+
+ private cascadeSelectNodesWithNoEvent(nodes?: IgxTreeNode[], clearPrevSelection = false): void {
+ const oldSelection = this.getSelectedNodes();
+
+ if (clearPrevSelection) {
+ this.indeterminateNodes.clear();
+ this.nodeSelection.clear();
+ this.calculateNodesNewSelectionState({ added: nodes, removed: [] });
+ } else {
+ const newSelection = [...oldSelection, ...nodes];
+ const args: Partial = { oldSelection, newSelection };
+
+ // retrieve only the rows without their parents/children which has to be added to the selection
+ this.populateAddRemoveArgs(args);
+
+ this.calculateNodesNewSelectionState(args);
+ }
+ this.nodeSelection = new Set(this.nodesToBeSelected);
+ this.indeterminateNodes = new Set(this.nodesToBeIndeterminate);
+
+ this.emitSelectedChangeEvent(oldSelection);
+ }
+
+ private cascadeDeselectNodesWithNoEvent(nodes: IgxTreeNode[]): void {
+ const args = { added: [], removed: nodes };
+ this.calculateNodesNewSelectionState(args);
+
+ this.nodeSelection = new Set>(this.nodesToBeSelected);
+ this.indeterminateNodes = new Set>(this.nodesToBeIndeterminate);
+ }
+
+ /**
+ * populates the nodesToBeSelected and nodesToBeIndeterminate sets
+ * with the nodes which will be eventually in selected/indeterminate state
+ */
+ private calculateNodesNewSelectionState(args: Partial): void {
+ this.nodesToBeSelected = new Set>(args.oldSelection ? args.oldSelection : this.getSelectedNodes());
+ this.nodesToBeIndeterminate = new Set>(this.getIndeterminateNodes());
+
+ this.cascadeSelectionState(args.removed, false);
+ this.cascadeSelectionState(args.added, true);
+ }
+
+ /** Ensures proper selection state for all predescessors and descendants during a selection event */
+ private cascadeSelectionState(nodes: IgxTreeNode[], selected: boolean): void {
+ if (!nodes || nodes.length === 0) {
+ return;
+ }
+
+ if (nodes && nodes.length > 0) {
+ const nodeCollection: CascadeSelectionNodeCollection = this.getCascadingNodeCollection(nodes);
+
+ nodeCollection.nodes.forEach(node => {
+ if (selected) {
+ this.nodesToBeSelected.add(node);
+ } else {
+ this.nodesToBeSelected.delete(node);
+ }
+ this.nodesToBeIndeterminate.delete(node);
+ });
+
+ Array.from(nodeCollection.parents).forEach((parent) => {
+ this.handleParentSelectionState(parent);
+ });
+ }
+ }
+
+ private emitCascadeNodeSelectionEvent(newSelection, added, removed, event?): boolean {
+ const currSelection = this.getSelectedNodes();
+ if (this.areEqualCollections(currSelection, newSelection)) {
+ return;
+ }
+
+ const args: ITreeNodeSelectionEvent = {
+ oldSelection: currSelection, newSelection,
+ added, removed, event, cancel: false, owner: this.tree
+ };
+
+ this.calculateNodesNewSelectionState(args);
+
+ args.newSelection = Array.from(this.nodesToBeSelected);
+
+ // retrieve nodes/parents/children which has been added/removed from the selection
+ this.populateAddRemoveArgs(args);
+
+ this.tree.nodeSelection.emit(args);
+
+ if (args.cancel) {
+ return;
+ }
+
+ // if args.newSelection hasn't been modified
+ if (this.areEqualCollections(Array.from(this.nodesToBeSelected), args.newSelection)) {
+ this.nodeSelection = new Set>(this.nodesToBeSelected);
+ this.indeterminateNodes = new Set(this.nodesToBeIndeterminate);
+ this.emitSelectedChangeEvent(currSelection);
+ } else {
+ // select the nodes within the modified args.newSelection with no event
+ this.cascadeSelectNodesWithNoEvent(args.newSelection, true);
+ }
+ }
+
+ /**
+ * recursively handle the selection state of the direct and indirect parents
+ */
+ private handleParentSelectionState(node: IgxTreeNode) {
+ if (!node) {
+ return;
+ }
+ this.handleNodeSelectionState(node);
+ if (node.parentNode) {
+ this.handleParentSelectionState(node.parentNode);
+ }
+ }
+
+ /**
+ * Handle the selection state of a given node based the selection states of its direct children
+ */
+ private handleNodeSelectionState(node: IgxTreeNode) {
+ const nodesArray = (node && node._children) ? node._children.toArray() : [];
+ if (nodesArray.length) {
+ if (nodesArray.every(n => this.nodesToBeSelected.has(n))) {
+ this.nodesToBeSelected.add(node);
+ this.nodesToBeIndeterminate.delete(node);
+ } else if (nodesArray.some(n => this.nodesToBeSelected.has(n) || this.nodesToBeIndeterminate.has(n))) {
+ this.nodesToBeIndeterminate.add(node);
+ this.nodesToBeSelected.delete(node);
+ } else {
+ this.nodesToBeIndeterminate.delete(node);
+ this.nodesToBeSelected.delete(node);
+ }
+ } else {
+ // if the children of the node has been deleted and the node was selected do not change its state
+ if (this.isNodeSelected(node)) {
+ this.nodesToBeSelected.add(node);
+ } else {
+ this.nodesToBeSelected.delete(node);
+ }
+ this.nodesToBeIndeterminate.delete(node);
+ }
+ }
+
+ /**
+ * Get a collection of all nodes affected by the change event
+ *
+ * @param nodesToBeProcessed set of the nodes to be selected/deselected
+ * @returns a collection of all affected nodes and all their parents
+ */
+ private getCascadingNodeCollection(nodes: IgxTreeNode[]): CascadeSelectionNodeCollection {
+ const collection: CascadeSelectionNodeCollection = {
+ parents: new Set>(),
+ nodes: new Set>(nodes)
+ };
+
+ Array.from(collection.nodes).forEach((node) => {
+ const nodeAndAllChildren = node.allChildren?.toArray() || [];
+ nodeAndAllChildren.forEach(n => {
+ collection.nodes.add(n);
+ });
+
+ if (node && node.parentNode) {
+ collection.parents.add(node.parentNode);
+ }
+ });
+ return collection;
+ }
+
+ /**
+ * retrieve the nodes which should be added/removed to/from the old selection
+ */
+ private populateAddRemoveArgs(args: Partial): void {
+ args.removed = args.oldSelection.filter(x => args.newSelection.indexOf(x) < 0);
+ args.added = args.newSelection.filter(x => args.oldSelection.indexOf(x) < 0);
+ }
+
+ /** Emits the `selectedChange` event for each node affected by the selection */
+ private emitSelectedChangeEvent(oldSelection: IgxTreeNode[]): void {
+ this.getSelectedNodes().forEach(n => {
+ if (oldSelection.indexOf(n) < 0) {
+ n.selectedChange.emit(true);
+ }
+ });
+
+ oldSelection.forEach(n => {
+ if (!this.nodeSelection.has(n)) {
+ n.selectedChange.emit(false);
+ }
+ });
+ }
+}
diff --git a/projects/igniteui-angular/src/lib/tree/tree-selection.spec.ts b/projects/igniteui-angular/src/lib/tree/tree-selection.spec.ts
new file mode 100644
index 00000000000..172f99de3d7
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree-selection.spec.ts
@@ -0,0 +1,646 @@
+import { TestBed, fakeAsync, waitForAsync } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { configureTestSuite } from '../test-utils/configure-suite';
+import { EventEmitter, QueryList } from '@angular/core';
+import { IgxTreeComponent, IgxTreeModule } from './tree.component';
+import { UIInteractions } from '../test-utils/ui-interactions.spec';
+import { TreeTestFunctions, TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS } from './tree-functions.spec';
+import { IgxTree, IgxTreeSelectionType, ITreeNodeSelectionEvent } from './common';
+import { IgxTreeSelectionService } from './tree-selection.service';
+import { IgxTreeService } from './tree.service';
+import { IgxTreeNodeComponent } from './tree-node/tree-node.component';
+import { IgxTreeNavigationService } from './tree-navigation.service';
+import { IgxTreeSelectionSampleComponent, IgxTreeSimpleComponent } from './tree-samples.spec';
+
+describe('IgxTree - Selection #treeView', () => {
+ configureTestSuite();
+ beforeAll(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ IgxTreeSimpleComponent,
+ IgxTreeSelectionSampleComponent
+ ],
+ imports: [IgxTreeModule, NoopAnimationsModule]
+ }).compileComponents();
+ }));
+
+ describe('UI Interaction tests - None & BiState', () => {
+ let fix;
+ let tree: IgxTreeComponent;
+
+ beforeEach(fakeAsync(() => {
+ fix = TestBed.createComponent(IgxTreeSimpleComponent);
+ fix.detectChanges();
+ tree = fix.componentInstance.tree;
+ tree.selection = IgxTreeSelectionType.BiState;
+ fix.detectChanges();
+ }));
+
+ it('Should have checkbox on each node if selection mode is BiState', () => {
+ const nodes = TreeTestFunctions.getAllNodes(fix);
+ expect(nodes.length).toBe(4);
+ nodes.forEach((node) => {
+ const checkBoxElement = node.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`);
+ expect(checkBoxElement).not.toBeNull();
+ });
+
+ tree.selection = IgxTreeSelectionType.None;
+ fix.detectChanges();
+
+ expect(nodes.length).toBe(4);
+ nodes.forEach((node) => {
+ const checkBoxElement = node.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`);
+ expect(checkBoxElement).toBeNull();
+ });
+ });
+
+ it('Should be able to change node selection to None', () => {
+ expect(tree.selection).toEqual(IgxTreeSelectionType.BiState);
+ const firstNode = tree.nodes.toArray()[0];
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+ TreeTestFunctions.verifyNodeSelected(firstNode);
+
+ tree.selection = IgxTreeSelectionType.None;
+ fix.detectChanges();
+ expect(tree.selection).toEqual(IgxTreeSelectionType.None);
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, false);
+ });
+
+ it('Should be able to change node selection to Cascading', () => {
+ expect(tree.selection).toEqual(IgxTreeSelectionType.BiState);
+ const firstNode = tree.nodes.toArray()[0];
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+ TreeTestFunctions.verifyNodeSelected(firstNode);
+
+ tree.selection = IgxTreeSelectionType.Cascading;
+ fix.detectChanges();
+ expect(tree.selection).toEqual(IgxTreeSelectionType.Cascading);
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ });
+
+ it('Click on checkbox should call node`s onSelectorClick method', () => {
+ const firstNode = tree.nodes.toArray()[0];
+ spyOn(firstNode, 'onSelectorClick').and.callThrough();
+
+ const ev = TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+
+ expect(firstNode.onSelectorClick).toHaveBeenCalledTimes(1);
+ expect(firstNode.onSelectorClick).toHaveBeenCalledWith(ev);
+ });
+
+ it('Checkbox should correctly represent the node`s selection state', () => {
+ const firstNode = tree.nodes.toArray()[0];
+ firstNode.selected = true;
+ fix.detectChanges();
+
+ const secondNode = tree.nodes.toArray()[1];
+
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ });
+
+ it('Nodes should be selected only from checkboxes', () => {
+ const firstNode = tree.nodes.toArray()[0];
+ firstNode.expanded = true;
+ fix.detectChanges();
+ const secondNode = tree.nodes.toArray()[1];
+
+ UIInteractions.simulateClickEvent(firstNode.nativeElement);
+ fix.detectChanges();
+ UIInteractions.simulateClickEvent(secondNode.nativeElement);
+ fix.detectChanges();
+
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ });
+
+ it('Should select multiple nodes with Shift + Click', () => {
+ tree.nodes.toArray()[0].expanded = true;
+ fix.detectChanges();
+ const firstNode = tree.nodes.toArray()[10];
+
+ tree.nodes.toArray()[14].expanded = true;
+ fix.detectChanges();
+ const secondNode = tree.nodes.toArray()[15];
+
+ const mockEvent = new MouseEvent('click', { shiftKey: true });
+
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+
+ TreeTestFunctions.verifyNodeSelected(firstNode);
+
+ // Click on other node holding Shift key
+ secondNode.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`).dispatchEvent(mockEvent);
+ fix.detectChanges();
+
+ for (let index = 10; index < 16; index++) {
+ const node = tree.nodes.toArray()[index];
+ TreeTestFunctions.verifyNodeSelected(node);
+ }
+ });
+
+ it('Should be able to cancel nodeSelection event', () => {
+ const firstNode = tree.nodes.toArray()[0];
+
+ tree.nodeSelection.subscribe((e: any) => {
+ e.cancel = true;
+ });
+
+ // Click on a node checkbox
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ });
+
+ it('Should be able to programmatically overwrite the selection using nodeSelection event', () => {
+ const firstNode = tree.nodes.toArray()[0];
+
+ tree.nodeSelection.subscribe((e: any) => {
+ e.newSelection = [tree.nodes.toArray()[1], tree.nodes.toArray()[14]];
+ });
+
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[1]);
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[14]);
+ });
+ });
+
+ describe('UI Interaction tests - Cascading', () => {
+ let fix;
+ let tree: IgxTreeComponent;
+
+ beforeEach(fakeAsync(() => {
+ fix = TestBed.createComponent(IgxTreeSimpleComponent);
+ fix.detectChanges();
+ tree = fix.componentInstance.tree;
+ tree.selection = IgxTreeSelectionType.Cascading;
+ fix.detectChanges();
+ }));
+
+ it('Should have checkbox on each node if selection mode is Cascading', () => {
+ const nodes = TreeTestFunctions.getAllNodes(fix);
+ expect(nodes.length).toBe(4);
+ nodes.forEach((node) => {
+ const checkBoxElement = node.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`);
+ expect(checkBoxElement).not.toBeNull();
+ });
+ });
+
+ it('Should be able to change node selection to None', () => {
+ expect(tree.selection).toEqual(IgxTreeSelectionType.Cascading);
+ TreeTestFunctions.clickNodeCheckbox(tree.nodes.toArray()[10]);
+ fix.detectChanges();
+
+ for (let i = 10; i < 14; i++) {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]);
+ }
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, true, true);
+
+ tree.selection = IgxTreeSelectionType.None;
+ fix.detectChanges();
+
+ expect(tree.selection).toEqual(IgxTreeSelectionType.None);
+ for (let i = 10; i < 14; i++) {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false, false);
+ }
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, false);
+ });
+
+ it('Should be able to change node selection to BiState', () => {
+ expect(tree.selection).toEqual(IgxTreeSelectionType.Cascading);
+ TreeTestFunctions.clickNodeCheckbox(tree.nodes.toArray()[10]);
+ fix.detectChanges();
+
+ for (let i = 10; i < 14; i++) {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]);
+ }
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, true, true);
+
+ tree.selection = IgxTreeSelectionType.BiState;
+ fix.detectChanges();
+
+ expect(tree.selection).toEqual(IgxTreeSelectionType.BiState);
+ for (let i = 10; i < 14; i++) {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false);
+ }
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false);
+ });
+
+ it('Checkbox should correctly represent the node`s selection state', () => {
+ const firstNode = tree.nodes.toArray()[0];
+ const secondNode = tree.nodes.toArray()[10];
+ secondNode.selected = true;
+ fix.detectChanges();
+
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ for (let i = 1; i < 14; i++) {
+ if (i < 10) {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false);
+ } else {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]);
+ }
+ }
+ });
+
+ it('Should select multiple nodes with Shift + Click', () => {
+ const firstNode = tree.nodes.toArray()[10];
+ const secondNode = tree.nodes.toArray()[15];
+
+ const mockEvent = new MouseEvent('click', { shiftKey: true });
+
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+
+ TreeTestFunctions.verifyNodeSelected(firstNode);
+
+ // Click on other node holding Shift key
+ secondNode.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`).dispatchEvent(mockEvent);
+ fix.detectChanges();
+
+ for (let index = 10; index < 21; index++) {
+ const node = tree.nodes.toArray()[index];
+ TreeTestFunctions.verifyNodeSelected(node);
+ }
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, true, true);
+ });
+
+ it('Should be able to cancel nodeSelection event', () => {
+ const firstNode = tree.nodes.toArray()[0];
+
+ tree.nodeSelection.subscribe((e: any) => {
+ e.cancel = true;
+ });
+
+ // Click on a node checkbox
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ });
+
+ it('Should be able to programmatically overwrite the selection using nodeSelection event', () => {
+ const firstNode = tree.nodes.toArray()[0];
+
+ tree.nodeSelection.subscribe((e: any) => {
+ e.newSelection = [tree.nodes.toArray()[10], tree.nodes.toArray()[15]];
+ });
+
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ for (let i = 10; i < 18; i++) {
+ if (i !== 14) {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]);
+ } else {
+ TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false, true, true);
+ }
+ }
+ });
+ });
+
+ describe('UI Interaction - Two-Way Binding', () => {
+ let fix;
+ let tree: IgxTreeComponent;
+
+ beforeEach(fakeAsync(() => {
+ fix = TestBed.createComponent(IgxTreeSelectionSampleComponent);
+ fix.detectChanges();
+ tree = fix.componentInstance.tree;
+ tree.selection = IgxTreeSelectionType.BiState;
+ fix.detectChanges();
+ }));
+
+ it('Should correctly represent the node`s selection state on click', () => {
+ const firstNode = tree.nodes.toArray()[0];
+ firstNode.expanded = true;
+ fix.detectChanges();
+ const secondNode = tree.nodes.toArray()[1];
+ secondNode.expanded = true;
+ fix.detectChanges();
+ const thirdNode = tree.nodes.toArray()[2];
+
+ TreeTestFunctions.clickNodeCheckbox(thirdNode);
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ TreeTestFunctions.clickNodeCheckbox(thirdNode);
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeFalsy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, false);
+ });
+
+ it('Should correctly represent the node`s selection state when changing node`s selected property', () => {
+ const firstNode = tree.nodes.toArray()[0];
+ const secondNode = tree.nodes.toArray()[1];
+ const thirdNode = tree.nodes.toArray()[2];
+ thirdNode.selected = true;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ firstNode.selected = true;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ thirdNode.selected = false;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeFalsy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, false);
+ });
+
+ it('Should correctly represent the node`s selection state when changing data selected property', () => {
+ const firstNode = tree.nodes.toArray()[0];
+ const secondNode = tree.nodes.toArray()[1];
+ const thirdNode = tree.nodes.toArray()[2];
+ thirdNode.data.selected = true;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ firstNode.data.selected = true;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ thirdNode.data.selected = false;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeFalsy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, false);
+ });
+
+ it('Should correctly represent the node`s selection state on click in Cascading mode', () => {
+ tree.selection = IgxTreeSelectionType.Cascading;
+ fix.detectChanges();
+
+ const firstNode = tree.nodes.toArray()[0];
+ firstNode.expanded = true;
+ fix.detectChanges();
+ const secondNode = tree.nodes.toArray()[1];
+ secondNode.expanded = true;
+ fix.detectChanges();
+ const thirdNode = tree.nodes.toArray()[2];
+
+ TreeTestFunctions.clickNodeCheckbox(thirdNode);
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true, true, false);
+
+ TreeTestFunctions.clickNodeCheckbox(firstNode);
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeTruthy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ TreeTestFunctions.clickNodeCheckbox(thirdNode);
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeFalsy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, false, true, false);
+ });
+
+ it('Should correctly represent the node`s selection state when changing node`s selected property in Cascading mode', () => {
+ tree.selection = IgxTreeSelectionType.Cascading;
+ fix.detectChanges();
+
+ const firstNode = tree.nodes.toArray()[0];
+ const secondNode = tree.nodes.toArray()[1];
+ const thirdNode = tree.nodes.toArray()[2];
+ thirdNode.selected = true;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true, true, false);
+
+ firstNode.selected = true;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeTruthy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ thirdNode.selected = false;
+ fix.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeFalsy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, false, true, false);
+ });
+
+ it('Should correctly represent the node`s selection state when changing data selected property in Cascading mode', () => {
+ tree.selection = IgxTreeSelectionType.Cascading;
+ fix.detectChanges();
+
+ const firstNode = tree.nodes.toArray()[0];
+ const secondNode = tree.nodes.toArray()[1];
+ const thirdNode = tree.nodes.toArray()[2];
+
+ thirdNode.data.selected = true;
+ fix.componentInstance.cdr.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true, true, false);
+
+ firstNode.data.selected = true;
+ fix.componentInstance.cdr.detectChanges();
+
+ expect(firstNode.data.selected).toBeTruthy();
+ expect(secondNode.data.selected).toBeTruthy();
+ expect(thirdNode.data.selected).toBeTruthy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, true);
+
+ thirdNode.data.selected = false;
+ fix.componentInstance.cdr.detectChanges();
+
+ expect(firstNode.data.selected).toBeFalsy();
+ expect(secondNode.data.selected).toBeFalsy();
+ expect(thirdNode.data.selected).toBeFalsy();
+ TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true);
+ TreeTestFunctions.verifyNodeSelected(thirdNode, false, true, false);
+ });
+ });
+
+ describe('IgxTree - API Tests', () => {
+ let mockNodes: IgxTreeNodeComponent[];
+ let mockQuery: jasmine.SpyObj>;
+ const selectionService = new IgxTreeSelectionService();
+ const treeService = new IgxTreeService();
+ const navService = new IgxTreeNavigationService(treeService, selectionService);
+ const tree = new IgxTreeComponent(navService, selectionService, treeService, null);
+
+ beforeEach(() => {
+ mockNodes = TreeTestFunctions.createNodeSpies(0, 5);
+ mockQuery = TreeTestFunctions.createQueryListSpy(mockNodes);
+ mockQuery.toArray.and.returnValue(mockNodes);
+ mockQuery.forEach.and.callFake((cb) => mockNodes.forEach(cb));
+
+ tree.selection = IgxTreeSelectionType.BiState;
+ (tree.nodes as any) = mockQuery;
+ });
+
+ it('Should be able to deselect all nodes', () => {
+ spyOn(selectionService, 'deselectNodesWithNoEvent').and.callThrough();
+
+ tree.nodes.forEach(node => node.selected = true);
+
+ tree.deselectAll();
+ expect((tree as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalled();
+ expect((tree as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith(undefined);
+ });
+
+ it('Should be able to deselect multiple nodes', () => {
+ spyOn(selectionService, 'deselectNodesWithNoEvent').and.callThrough();
+
+ tree.nodes.toArray()[0].selected = true;
+ tree.nodes.toArray()[1].selected = true;
+
+ tree.deselectAll([tree.nodes.toArray()[0], tree.nodes.toArray()[1]]);
+ expect((tree as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalled();
+ expect((tree as any).selectionService.deselectNodesWithNoEvent)
+ .toHaveBeenCalledWith([tree.nodes.toArray()[0], tree.nodes.toArray()[1]]);
+ });
+ navService.ngOnDestroy();
+ tree.ngOnDestroy();
+ });
+
+ describe('IgxTreeNode - API Tests', () => {
+ const elementRef = { nativeElement: null };
+ const selectionService = new IgxTreeSelectionService();
+ const treeService = new IgxTreeService();
+ const navService = new IgxTreeNavigationService(treeService, selectionService);
+ const mockEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']);;
+ const mockTree: IgxTree = jasmine.createSpyObj('tree', [''],
+ { selection: IgxTreeSelectionType.BiState, nodeSelection: mockEmitter, nodes: {
+ find: () => true
+ } });
+ const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck', 'detectChanges']);
+ selectionService.register(mockTree);
+
+ const node = new IgxTreeNodeComponent(mockTree, selectionService, treeService, navService, mockCdr, null, elementRef, null);
+
+ it('Should call selectNodesWithNoEvent when seting node`s selected property to true', () => {
+ spyOn(selectionService, 'selectNodesWithNoEvent').and.callThrough();
+ node.selected = true;
+
+ expect((node as any).selectionService.selectNodesWithNoEvent).toHaveBeenCalled();
+ expect((node as any).selectionService.selectNodesWithNoEvent).toHaveBeenCalledWith([node]);
+ });
+
+ it('Should call deselectNodesWithNoEvent when seting node`s selected property to false', () => {
+ spyOn(selectionService, 'deselectNodesWithNoEvent').and.callThrough();
+ node.selected = false;
+
+ expect((node as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalled();
+ expect((node as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith([node]);
+ });
+
+ it('Should call isNodeSelected when node`s selected getter is invoked', () => {
+ spyOn(selectionService, 'isNodeSelected').and.callThrough();
+ const isSelected = node.selected;
+
+ expect(isSelected).toBeFalse();
+ expect((node as any).selectionService.isNodeSelected).toHaveBeenCalled();
+ expect((node as any).selectionService.isNodeSelected).toHaveBeenCalledWith(node);
+ });
+
+ it('Should call isNodeIndeterminate when node`s indeterminate getter is invoked', () => {
+ spyOn(selectionService, 'isNodeIndeterminate').and.callThrough();
+ const isIndeterminate = node.indeterminate;
+
+ expect(isIndeterminate).toBeFalse();
+ expect((node as any).selectionService.isNodeIndeterminate).toHaveBeenCalled();
+ expect((node as any).selectionService.isNodeIndeterminate).toHaveBeenCalledWith(node);
+ });
+
+ navService.ngOnDestroy();
+ });
+});
+
diff --git a/projects/igniteui-angular/src/lib/tree/tree.component.html b/projects/igniteui-angular/src/lib/tree/tree.component.html
new file mode 100644
index 00000000000..98128089e56
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree.component.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/projects/igniteui-angular/src/lib/tree/tree.component.ts b/projects/igniteui-angular/src/lib/tree/tree.component.ts
new file mode 100644
index 00000000000..f558012c8d0
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree.component.ts
@@ -0,0 +1,515 @@
+import { CommonModule } from '@angular/common';
+import {
+ Component, QueryList, Input, Output, EventEmitter, ContentChild, Directive,
+ NgModule, TemplateRef, OnInit, AfterViewInit, ContentChildren, OnDestroy, HostBinding, ElementRef, Optional, Inject
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { growVerIn, growVerOut } from '../animations/grow';
+import { IgxCheckboxModule } from '../checkbox/checkbox.component';
+import { DisplayDensityBase, DisplayDensityToken, IDisplayDensityOptions } from '../core/displayDensity';
+import { IgxExpansionPanelModule } from '../expansion-panel/public_api';
+import { ToggleAnimationSettings } from '../expansion-panel/toggle-animation-component';
+import { IgxIconModule } from '../icon/public_api';
+import { IgxInputGroupModule } from '../input-group/public_api';
+import { IgxProgressBarModule } from '../progressbar/progressbar.component';
+import {
+ IGX_TREE_COMPONENT, IgxTreeSelectionType, IgxTree, ITreeNodeToggledEventArgs,
+ ITreeNodeTogglingEventArgs, ITreeNodeSelectionEvent, IgxTreeNode, IgxTreeSearchResolver
+} from './common';
+import { IgxTreeNavigationService } from './tree-navigation.service';
+import { IgxTreeNodeComponent, IgxTreeNodeLinkDirective } from './tree-node/tree-node.component';
+import { IgxTreeSelectionService } from './tree-selection.service';
+import { IgxTreeService } from './tree.service';
+
+/**
+ * @hidden @internal
+ * Used for templating the select marker of the tree
+ */
+@Directive({
+ selector: '[igxTreeSelectMarker]'
+})
+export class IgxTreeSelectMarkerDirective {
+}
+
+/**
+ * @hidden @internal
+ * Used for templating the expand indicator of the tree
+ */
+@Directive({
+ selector: '[igxTreeExpandIndicator]'
+})
+export class IgxTreeExpandIndicatorDirective {
+}
+
+@Component({
+ selector: 'igx-tree',
+ templateUrl: 'tree.component.html',
+ providers: [
+ IgxTreeService,
+ IgxTreeSelectionService,
+ IgxTreeNavigationService,
+ { provide: IGX_TREE_COMPONENT, useExisting: IgxTreeComponent },
+ ]
+})
+export class IgxTreeComponent extends DisplayDensityBase implements IgxTree, OnInit, AfterViewInit, OnDestroy {
+
+ @HostBinding('class.igx-tree')
+ public cssClass = 'igx-tree';
+
+ /**
+ * Gets/Sets tree selection mode
+ *
+ * @remarks
+ * By default the tree selection mode is 'None'
+ * @param selectionMode: IgxTreeSelectionType
+ */
+ @Input()
+ public get selection() {
+ return this._selection;
+ }
+
+ public set selection(selectionMode: IgxTreeSelectionType) {
+ this._selection = selectionMode;
+ this.selectionService.clearNodesSelection();
+ }
+
+ /** Get/Set how the tree should handle branch expansion.
+ * If set to `true`, only a single branch can be expanded at a time, collapsing all others
+ *
+ * ```html
+ *
+ * ...
+ *
+ * ```
+ *
+ * ```typescript
+ * const tree: IgxTree = this.tree;
+ * this.tree.singleBranchExpand = false;
+ * ```
+ */
+ @Input()
+ public singleBranchExpand = false;
+
+ /** Get/Set the animation settings that branches should use when expanding/collpasing.
+ *
+ * ```html
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * const animationSettings: ToggleAnimationSettings = {
+ * openAnimation: growVerIn,
+ * closeAnimation: growVerOut
+ * };
+ *
+ * this.tree.animationSettings = animationSettings;
+ * ```
+ */
+ @Input()
+ public animationSettings: ToggleAnimationSettings = {
+ openAnimation: growVerIn,
+ closeAnimation: growVerOut
+ };
+
+ /** Emitted when the node selection is changed through interaction
+ *
+ * ```html
+ *
+ *
+ * ```
+ *
+ *```typescript
+ * public handleNodeSelection(event: ITreeNodeSelectionEvent) {
+ * const newSelection: IgxTreeNode[] = event.newSelection;
+ * const added: IgxTreeNode[] = event.added;
+ * console.log("New selection will be: ", newSelection);
+ * console.log("Added nodes: ", event.added);
+ * }
+ *```
+ */
+ @Output()
+ public nodeSelection = new EventEmitter();
+
+ /** Emitted when a node is expanding, before it finishes
+ *
+ * ```html
+ *
+ *
+ * ```
+ *
+ *```typescript
+ * public handleNodeExpanding(event: ITreeNodeTogglingEventArgs) {
+ * const expandedNode: IgxTreeNode = event.node;
+ * if (expandedNode.disabled) {
+ * event.cancel = true;
+ * }
+ * }
+ *```
+ */
+ @Output()
+ public nodeExpanding = new EventEmitter();
+
+ /** Emitted when a node is expanded, after it finishes
+ *
+ * ```html
+ *
+ *
+ * ```
+ *
+ *```typescript
+ * public handleNodeExpanded(event: ITreeNodeToggledEventArgs) {
+ * const expandedNode: IgxTreeNode = event.node;
+ * console.log("Node is expanded: ", expandedNode.data);
+ * }
+ *```
+ */
+ @Output()
+ public nodeExpanded = new EventEmitter();
+
+ /** Emitted when a node is collapsing, before it finishes
+ *
+ * ```html
+ *
+ *
+ * ```
+ *
+ *```typescript
+ * public handleNodeCollapsing(event: ITreeNodeTogglingEventArgs) {
+ * const collapsedNode: IgxTreeNode = event.node;
+ * if (collapsedNode.alwaysOpen) {
+ * event.cancel = true;
+ * }
+ * }
+ *```
+ */
+ @Output()
+ public nodeCollapsing = new EventEmitter();
+
+ /** Emitted when a node is collapsed, after it finishes
+ *
+ * @example
+ * ```html
+ *
+ *
+ * ```
+ * ```typescript
+ * public handleNodeCollapsed(event: ITreeNodeToggledEventArgs) {
+ * const collapsedNode: IgxTreeNode = event.node;
+ * console.log("Node is collapsed: ", collapsedNode.data);
+ * }
+ * ```
+ */
+ @Output()
+ public nodeCollapsed = new EventEmitter();
+
+ /**
+ * Emitted when the active node is changed.
+ *
+ * @example
+ * ```
+ *
+ * ```
+ */
+ @Output()
+ public activeNodeChanged = new EventEmitter>();
+
+ /**
+ * A custom template to be used for the expand indicator of nodes
+ * ```html
+ *
+ *
+ * {{ expanded ? "close_fullscreen": "open_in_full"}}
+ *
+ *
+ * ```
+ */
+ @ContentChild(IgxTreeExpandIndicatorDirective, { read: TemplateRef })
+ public expandIndicator: TemplateRef;
+
+ /** @hidden @internal */
+ @ContentChildren(IgxTreeNodeComponent, { descendants: true })
+ public nodes: QueryList>;
+
+ /** @hidden @internal */
+ public disabledChange = new EventEmitter>();
+
+ /**
+ * Returns all **root level** nodes
+ *
+ * ```typescript
+ * const tree: IgxTree = this.tree;
+ * const rootNodes: IgxTreeNodeComponent[] = tree.rootNodes;
+ * ```
+ */
+ public get rootNodes(): IgxTreeNodeComponent[] {
+ return this.nodes?.filter(node => node.level === 0);
+ }
+
+ /**
+ * Emitted when the active node is set through API
+ *
+ * @hidden @internal
+ */
+ public activeNodeBindingChange = new EventEmitter>();
+
+ /** @hidden @internal */
+ public forceSelect = [];
+
+ private _selection: IgxTreeSelectionType = IgxTreeSelectionType.None;
+ private destroy$ = new Subject();
+ private unsubChildren$ = new Subject();
+
+ constructor(
+ private navService: IgxTreeNavigationService,
+ private selectionService: IgxTreeSelectionService,
+ private treeService: IgxTreeService,
+ private element: ElementRef,
+ @Optional() @Inject(DisplayDensityToken) protected _displayDensityOptions?: IDisplayDensityOptions) {
+ super(_displayDensityOptions);
+ this.selectionService.register(this);
+ this.treeService.register(this);
+ this.navService.register(this);
+ }
+
+ /** @hidden @internal */
+ public get nativeElement() {
+ return this.element.nativeElement;
+ }
+
+ /**
+ * Expands all of the passed nodes.
+ * If no nodes are passed, expands ALL nodes
+ *
+ * @param nodes nodes to be expanded
+ *
+ * ```typescript
+ * const targetNodes: IgxTreeNode = this.tree.findNodes(true, (_data: any, node: IgxTreeNode) => node.data.expandable);
+ * tree.expandAll(nodes);
+ * ```
+ */
+ public expandAll(nodes?: IgxTreeNode[]) {
+ nodes = nodes || this.nodes.toArray();
+ nodes.forEach(e => e.expanded = true);
+ }
+
+ /**
+ * Collapses all of the passed nodes.
+ * If no nodes are passed, collapses ALL nodes
+ *
+ * @param nodes nodes to be collapsed
+ *
+ * ```typescript
+ * const targetNodes: IgxTreeNode = this.tree.findNodes(true, (_data: any, node: IgxTreeNode) => node.data.collapsible);
+ * tree.collapseAll(nodes);
+ * ```
+ */
+ public collapseAll(nodes?: IgxTreeNode[]) {
+ nodes = nodes || this.nodes.toArray();
+ nodes.forEach(e => e.expanded = false);
+ }
+
+ /**
+ * Deselect all nodes if the nodes collection is empty. Otherwise, deselect the nodes in the nodes collection.
+ *
+ * @example
+ * ```typescript
+ * const arr = [
+ * this.tree.nodes.toArray()[0],
+ * this.tree.nodes.toArray()[1]
+ * ];
+ * this.tree.deselectAll(arr);
+ * ```
+ * @param nodes: IgxTreeNodeComponent[]
+ */
+ public deselectAll(nodes?: IgxTreeNodeComponent[]) {
+ this.selectionService.deselectNodesWithNoEvent(nodes);
+ }
+
+ /**
+ * Returns all of the nodes that match the passed searchTerm.
+ * Accepts a custom comparer function for evaluating the search term against the nodes.
+ *
+ * @remark
+ * Default search compares the passed `searchTerm` against the node's `data` Input.
+ * When using `findNodes` w/o a `comparer`, make sure all nodes have `data` passed.
+ *
+ * @param searchTerm The data of the searched node
+ * @param comparer A custom comparer function that evaluates the passed `searchTerm` against all nodes.
+ * @returns Array of nodes that match the search. `null` if no nodes are found.
+ *
+ * ```html
+ *
+ *
+ * {{ node.label }}
+ *
+ *
+ * ```
+ *
+ * ```typescript
+ * public data: DataEntry[] = FETCHED_DATA;
+ * ...
+ * const matchedNodes: IgxTreeNode[] = this.tree.findNodes(searchTerm: data[5]);
+ * ```
+ *
+ * Using a custom comparer
+ * ```typescript
+ * public data: DataEntry[] = FETCHED_DATA;
+ * ...
+ * const comparer: IgxTreeSearchResolver = (data: any, node: IgxTreeNode) {
+ * return node.data.index % 2 === 0;
+ * }
+ * const evenIndexNodes: IgxTreeNode[] = this.tree.findNodes(null, comparer);
+ * ```
+ */
+ public findNodes(searchTerm: any, comparer?: IgxTreeSearchResolver): IgxTreeNodeComponent[] | null {
+ const compareFunc = comparer || this._comparer;
+ const results = this.nodes.filter(node => compareFunc(searchTerm, node));
+ return results?.length === 0 ? null : results;
+ }
+
+ /** @hidden @internal */
+ public handleKeydown(event: KeyboardEvent) {
+ this.navService.handleKeydown(event);
+ }
+
+ /** @hidden @internal */
+ public ngOnInit() {
+ super.ngOnInit();
+ this.disabledChange.pipe(takeUntil(this.destroy$)).subscribe((e) => {
+ this.navService.update_disabled_cache(e);
+ });
+ this.activeNodeBindingChange.pipe(takeUntil(this.destroy$)).subscribe((node) => {
+ this.expandToNode(this.navService.activeNode);
+ this.scrollNodeIntoView(node?.header?.nativeElement);
+ });
+ this.onDensityChanged.pipe(takeUntil(this.destroy$)).subscribe(() => {
+ requestAnimationFrame(() => {
+ this.scrollNodeIntoView(this.navService.activeNode?.header.nativeElement);
+ });
+ });
+ this.subToCollapsing();
+ }
+
+ /** @hidden @internal */
+ public ngAfterViewInit() {
+ this.nodes.changes.pipe(takeUntil(this.destroy$)).subscribe(() => {
+ this.subToChanges();
+ });
+ this.scrollNodeIntoView(this.navService.activeNode?.header?.nativeElement);
+ this.subToChanges();
+ }
+
+ /** @hidden @internal */
+ public ngOnDestroy() {
+ this.unsubChildren$.next();
+ this.unsubChildren$.complete();
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private expandToNode(node: IgxTreeNode) {
+ if (node && node.parentNode) {
+ node.path.forEach(n => {
+ if (n !== node && !n.expanded) {
+ n.expanded = true;
+ }
+ });
+ }
+ }
+
+ private subToCollapsing() {
+ this.nodeCollapsing.pipe(takeUntil(this.destroy$)).subscribe(event => {
+ if (event.cancel) {
+ return;
+ }
+ this.navService.update_visible_cache(event.node, false);
+ });
+ this.nodeExpanding.pipe(takeUntil(this.destroy$)).subscribe(event => {
+ if (event.cancel) {
+ return;
+ }
+ this.navService.update_visible_cache(event.node, true);
+ });
+ }
+
+ private subToChanges() {
+ this.unsubChildren$.next();
+ const toBeSelected = [...this.forceSelect];
+ requestAnimationFrame(() => {
+ this.selectionService.selectNodesWithNoEvent(toBeSelected);
+ });
+ this.forceSelect = [];
+ this.nodes.forEach(node => {
+ node.expandedChange.pipe(takeUntil(this.unsubChildren$)).subscribe(nodeState => {
+ this.navService.update_visible_cache(node, nodeState);
+ });
+ node.closeAnimationDone.pipe(takeUntil(this.unsubChildren$)).subscribe(() => {
+ const targetElement = this.navService.focusedNode?.header.nativeElement;
+ this.scrollNodeIntoView(targetElement);
+ });
+ node.openAnimationDone.pipe(takeUntil(this.unsubChildren$)).subscribe(() => {
+ const targetElement = this.navService.focusedNode?.header.nativeElement;
+ this.scrollNodeIntoView(targetElement);
+ });
+ });
+ this.navService.init_invisible_cache();
+ }
+
+ private scrollNodeIntoView(el: HTMLElement) {
+ if (!el) {
+ return;
+ }
+ const nodeRect = el.getBoundingClientRect();
+ const treeRect = this.nativeElement.getBoundingClientRect();
+ const topOffset = treeRect.top > nodeRect.top ? nodeRect.top - treeRect.top : 0;
+ const bottomOffset = treeRect.bottom < nodeRect.bottom ? nodeRect.bottom - treeRect.bottom : 0;
+ const shouldScroll = !!topOffset || !!bottomOffset;
+ if (shouldScroll && this.nativeElement.scrollHeight > this.nativeElement.clientHeight) {
+ // this.nativeElement.scrollTop = nodeRect.y - treeRect.y - nodeRect.height;
+ this.nativeElement.scrollTop =
+ this.nativeElement.scrollTop + bottomOffset + topOffset + (topOffset ? -1 : +1) * nodeRect.height;
+ }
+ }
+
+ private _comparer = (data: T, node: IgxTreeNodeComponent) => node.data === data;
+
+}
+
+/**
+ * @hidden
+ *
+ * NgModule defining the components and directives needed for `igx-tree`
+ */
+@NgModule({
+ declarations: [
+ IgxTreeSelectMarkerDirective,
+ IgxTreeExpandIndicatorDirective,
+ IgxTreeNodeLinkDirective,
+ IgxTreeComponent,
+ IgxTreeNodeComponent
+ ],
+ imports: [
+ CommonModule,
+ FormsModule,
+ IgxIconModule,
+ IgxInputGroupModule,
+ IgxCheckboxModule,
+ IgxProgressBarModule
+ ],
+ exports: [
+ IgxTreeSelectMarkerDirective,
+ IgxTreeExpandIndicatorDirective,
+ IgxTreeNodeLinkDirective,
+ IgxTreeComponent,
+ IgxTreeNodeComponent,
+ IgxIconModule,
+ IgxInputGroupModule,
+ IgxCheckboxModule,
+ IgxExpansionPanelModule
+ ]
+})
+export class IgxTreeModule {
+}
diff --git a/projects/igniteui-angular/src/lib/tree/tree.service.ts b/projects/igniteui-angular/src/lib/tree/tree.service.ts
new file mode 100644
index 00000000000..b2aea95cad4
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree.service.ts
@@ -0,0 +1,71 @@
+import { Injectable } from '@angular/core';
+import { IgxTree, IgxTreeNode } from './common';
+
+/** @hidden @internal */
+@Injectable()
+export class IgxTreeService {
+ public expandedNodes: Set> = new Set>();
+ public collapsingNodes: Set> = new Set>();
+ private tree: IgxTree;
+
+ /**
+ * Adds the node to the `expandedNodes` set and fires the nodes change event
+ *
+ * @param node target node
+ * @param uiTrigger is the event triggered by a ui interraction (so we know if we should animate)
+ * @returns void
+ */
+ public expand(node: IgxTreeNode, uiTrigger?: boolean): void {
+ this.collapsingNodes.delete(node);
+ if (!this.expandedNodes.has(node)) {
+ node.expandedChange.emit(true);
+ } else {
+ return;
+ }
+ this.expandedNodes.add(node);
+ if (this.tree.singleBranchExpand) {
+ this.tree.findNodes(node, this.siblingComparer)?.forEach(e => {
+ if (uiTrigger) {
+ e.collapse();
+ } else {
+ e.expanded = false;
+ }
+ });
+ }
+ }
+
+ /**
+ * Adds a node to the `collapsing` collection
+ *
+ * @param node target node
+ */
+ public collapsing(node: IgxTreeNode): void {
+ this.collapsingNodes.add(node);
+ }
+
+ /**
+ * Removes the node from the 'expandedNodes' set and emits the node's change event
+ *
+ * @param node target node
+ * @returns void
+ */
+ public collapse(node: IgxTreeNode): void {
+ if (this.expandedNodes.has(node)) {
+ node.expandedChange.emit(false);
+ }
+ this.collapsingNodes.delete(node);
+ this.expandedNodes.delete(node);
+ }
+
+ public isExpanded(node: IgxTreeNode): boolean {
+ return this.expandedNodes.has(node);
+ }
+
+ public register(tree: IgxTree) {
+ this.tree = tree;
+ }
+
+ private siblingComparer:
+ (data: IgxTreeNode, node: IgxTreeNode) => boolean =
+ (data: IgxTreeNode, node: IgxTreeNode) => node !== data && node.level === data.level;
+}
diff --git a/projects/igniteui-angular/src/lib/tree/tree.spec.ts b/projects/igniteui-angular/src/lib/tree/tree.spec.ts
new file mode 100644
index 00000000000..27d461b7d79
--- /dev/null
+++ b/projects/igniteui-angular/src/lib/tree/tree.spec.ts
@@ -0,0 +1,705 @@
+import { AnimationBuilder } from '@angular/animations';
+import { Component, ElementRef, ViewChild, QueryList, EventEmitter, ChangeDetectorRef, DebugElement } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { DisplayDensity } from '../core/displayDensity';
+import { configureTestSuite } from '../test-utils/configure-suite';
+import { TreeTestFunctions } from './tree-functions.spec';
+import { IgxTreeNavigationService } from './tree-navigation.service';
+import { IgxTreeNodeComponent } from './tree-node/tree-node.component';
+import { IgxTreeSelectionService } from './tree-selection.service';
+import { IgxTreeComponent, IgxTreeModule } from './tree.component';
+import { IgxTreeService } from './tree.service';
+
+const TREE_ROOT_CLASS = 'igx-tree__root';
+const NODE_TAG = 'igx-tree-node';
+
+describe('IgxTree #treeView', () => {
+ configureTestSuite();
+ describe('Unit Tests', () => {
+ let mockNavService: IgxTreeNavigationService;
+ let mockTreeService: IgxTreeService;
+ let mockSelectionService: IgxTreeSelectionService;
+ let mockElementRef: ElementRef;
+ let mockNodes: QueryList>;
+ let mockNodesArray: IgxTreeNodeComponent[] = [];
+ let tree: IgxTreeComponent = null;
+ beforeEach(() => {
+ mockNodesArray = [];
+ mockNavService = jasmine.createSpyObj('navService',
+ ['register', 'update_disabled_cache', 'update_visible_cache',
+ 'init_invisible_cache', 'setFocusedAndActiveNode', 'handleKeydown']);
+ mockTreeService = jasmine.createSpyObj('treeService',
+ ['register', 'collapse', 'expand', 'collapsing', 'isExpanded']);
+ mockSelectionService = jasmine.createSpyObj('selectionService',
+ ['register', 'deselectNodesWithNoEvent', 'ensureStateOnNodeDelete', 'selectNodesWithNoEvent']);
+ mockElementRef = jasmine.createSpyObj('elementRef', [], {
+ nativeElement: jasmine.createSpyObj('nativeElement', ['focus'], {})
+ });
+ tree?.ngOnDestroy();
+ tree = new IgxTreeComponent(mockNavService, mockSelectionService, mockTreeService, mockElementRef);
+ mockNodes = jasmine.createSpyObj('mockList', ['toArray'], {
+ changes: new Subject(),
+ get first() {
+ return mockNodesArray[0];
+ },
+ get last() {
+ return mockNodesArray[mockNodesArray.length - 1];
+ },
+ get length() {
+ return mockNodesArray.length;
+ },
+ forEach: (cb: (n: IgxTreeNodeComponent) => void): void => {
+ mockNodesArray.forEach(cb);
+ },
+ find: (cb: (n: IgxTreeNodeComponent) => boolean): IgxTreeNodeComponent => mockNodesArray.find(cb),
+ filter: jasmine.createSpy('filter').
+ and.callFake((cb: (n: IgxTreeNodeComponent) => boolean): IgxTreeNodeComponent[] => mockNodesArray.filter(cb)),
+ });
+ spyOn(mockNodes, 'toArray').and.returnValue(mockNodesArray);
+ });
+ afterEach(() => {
+ tree?.ngOnDestroy();
+ });
+ describe('IgxTreeComponent', () => {
+ it('Should update nav children cache when events are fired', fakeAsync(() => {
+ expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(0);
+ expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(0);
+ expect(mockNavService.update_disabled_cache).toHaveBeenCalledTimes(0);
+ tree.ngOnInit();
+ tick();
+ expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(0);
+ expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(0);
+ expect(mockNavService.update_disabled_cache).toHaveBeenCalledTimes(0);
+ tree.disabledChange.emit('mockNode' as any);
+ tick();
+ expect(mockNavService.update_disabled_cache).toHaveBeenCalledTimes(1);
+ expect(mockNavService.update_disabled_cache).toHaveBeenCalledWith('mockNode' as any);
+ tree.nodeCollapsing.emit({ node: 'mockNode' as any } as any);
+ tick();
+ expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(1);
+ expect(mockNavService.update_visible_cache).toHaveBeenCalledWith('mockNode' as any, false);
+ tree.nodeExpanding.emit({ node: 'mockNode' as any } as any);
+ tick();
+ expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(2);
+ expect(mockNavService.update_visible_cache).toHaveBeenCalledWith('mockNode' as any, true);
+ tree.nodes = mockNodes;
+ const mockNode = TreeTestFunctions.createNodeSpy({
+ expandedChange: new EventEmitter