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 }} + node.imageAlt + + {{ 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 }} + node.imageAlt + + {{ 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(), + closeAnimationDone: new EventEmitter(), + openAnimationDone: new EventEmitter() + }) as any; + mockNodesArray.push( + mockNode + ); + console.log(mockNodesArray); + console.log(mockNodesArray[0]); + spyOnProperty(mockNodes, 'first', 'get').and.returnValue(mockNode); + tree.ngAfterViewInit(); + tick(); + expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(1); + tree.nodes.first.expandedChange.emit(true); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(3); + expect(mockNavService.update_visible_cache).toHaveBeenCalledWith(tree.nodes.first, true); + tree.nodes.first.expandedChange.emit(false); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(4); + expect(mockNavService.update_visible_cache).toHaveBeenCalledWith(tree.nodes.first, false); + (tree.nodes.changes as any).next(); + tick(); + expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(2); + tree.ngOnDestroy(); + })); + it('Should update delegate keyboard events to nav service', () => { + const mockEvent: any = {}; + tree.handleKeydown(mockEvent as any); + expect(mockNavService.handleKeydown).toHaveBeenCalledWith(mockEvent as any); + }); + it('Should search through nodes and return expected value w/ `findNodes`', () => { + tree.nodes = mockNodes; + let id = 0; + let itemRef = {} as any; + mockNodesArray = TreeTestFunctions.createNodeSpies(0, 5); + mockNodesArray.forEach(n => { + itemRef = { id: id++ }; + n.data = itemRef; + }); + expect(tree.findNodes(itemRef)).toEqual([mockNodesArray[mockNodesArray.length - 1]]); + expect(tree.nodes.filter).toHaveBeenCalledTimes(1); + expect(tree.findNodes(1, (p, n) => n.data.id === p)).toEqual([mockNodes.find(n => n.data.id === 1)]); + expect(tree.nodes.filter).toHaveBeenCalledTimes(2); + expect(tree.findNodes('Not found', (p, n) => n.data.id === p)).toEqual(null); + expect(tree.nodes.filter).toHaveBeenCalledTimes(3); + + }); + it('Should return only root level nodes w/ `rootNodes` accessor', () => { + tree.nodes = mockNodes; + const arr = []; + for (let i = 0; i < 7; i++) { + const level = i > 4 ? 1 : 0; + arr.push({ + level + }); + } + mockNodesArray = [...arr]; + expect(tree.rootNodes.length).toBe(5); + mockNodesArray.forEach(n => { + (n as any).level = 1; + }); + expect(tree.rootNodes.length).toBe(0); + mockNodesArray.forEach(n => { + (n as any).level = 0; + }); + expect(tree.rootNodes.length).toBe(7); + tree.nodes = null; + expect(tree.rootNodes).toBe(undefined); + }); + it('Should expandAll nodes nodes w/ proper methods', () => { + tree.nodes = mockNodes; + const customArrayParam = []; + for (let i = 0; i < 5; i++) { + const node = jasmine.createSpyObj('node', ['expand', 'collapse'], { + _expanded: false, + get expanded() { + return this._expanded; + }, + set expanded(val: boolean) { + this._expanded = val; + } + }); + node.spyProp = spyOnProperty(node, 'expanded', 'set').and.callThrough(); + mockNodesArray.push(node); + if (i > 3) { + customArrayParam.push(node); + } + } + spyOn(mockNodesArray, 'forEach').and.callThrough(); + tree.expandAll(); + expect(mockNodesArray.forEach).toHaveBeenCalledTimes(1); + mockNodesArray.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(true); + expect((n as any).spyProp).toHaveBeenCalledTimes(1); + }); + tree.expandAll(customArrayParam); + customArrayParam.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(true); + expect((n as any).spyProp).toHaveBeenCalledTimes(2); + }); + }); + it('Should collapseAll nodes nodes w/ proper methods', () => { + tree.nodes = mockNodes; + const customArrayParam = []; + for (let i = 0; i < 5; i++) { + const node = jasmine.createSpyObj('node', ['expand', 'collapse'], { + _expanded: false, + get expanded() { + return this._expanded; + }, + set expanded(val: boolean) { + this._expanded = val; + } + }); + node.spyProp = spyOnProperty(node, 'expanded', 'set').and.callThrough(); + mockNodesArray.push(node); + if (i > 3) { + customArrayParam.push(node); + } + } + spyOn(mockNodesArray, 'forEach').and.callThrough(); + tree.collapseAll(); + expect(mockNodesArray.forEach).toHaveBeenCalledTimes(1); + mockNodesArray.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(false); + expect((n as any).spyProp).toHaveBeenCalledTimes(1); + }); + tree.collapseAll(customArrayParam); + customArrayParam.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(false); + expect((n as any).spyProp).toHaveBeenCalledTimes(2); + }); + }); + it('Should deselectAll nodes w/ proper methond', () => { + tree.nodes = mockNodes; + tree.deselectAll(); + expect(mockSelectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith(undefined); + const customParam = jasmine.createSpyObj('nodes', ['toArray']); + tree.deselectAll(customParam); + expect(mockSelectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith(customParam); + }); + }); + describe('IgxTreeNodeComponent', () => { + let mockTree: IgxTreeComponent; + let mockCdr: ChangeDetectorRef; + let mockBuilder: AnimationBuilder; + + beforeEach(() => { + mockTree = jasmine.createSpyObj('mockTree', ['findNodes'], + { + nodeCollapsing: jasmine.createSpyObj('spy', ['emit']), + nodeExpanding: jasmine.createSpyObj('spy', ['emit']), + nodeCollapsed: jasmine.createSpyObj('spy', ['emit']), + nodeExpanded: jasmine.createSpyObj('spy', ['emit']), + _displayDensity: DisplayDensity.comfortable, + get displayDensity() { + return this._displayDensity; + } + }); + mockCdr = jasmine.createSpyObj('mockCdr', ['detectChanges', 'markForCheck'], {}); + mockBuilder = jasmine.createSpyObj('mockAB', ['build'], {}); + }); + it('Should call service expand/collapse methods when toggling state through `[expanded]` input', () => { + const node = new IgxTreeNodeComponent(mockTree, mockSelectionService, mockTreeService, + mockNavService, mockCdr, mockBuilder, mockElementRef, null); + expect(mockTreeService.collapse).not.toHaveBeenCalled(); + expect(mockTreeService.expand).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeCollapsed.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanding.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + node.expanded = true; + expect(mockTreeService.expand).toHaveBeenCalledTimes(1); + expect(mockTreeService.expand).toHaveBeenCalledWith(node, false); + node.expanded = false; + expect(mockTreeService.collapse).toHaveBeenCalledTimes(1); + expect(mockTreeService.collapse).toHaveBeenCalledWith(node); + // events are not emitted when chainging state through input + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeCollapsed.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanding.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + }); + it('Should call service expand/collapse methods when calling API state methods', () => { + const node = new IgxTreeNodeComponent(mockTree, mockSelectionService, mockTreeService, + mockNavService, mockCdr, mockBuilder, mockElementRef, null); + const emitSpy = spyOn(node, 'expandedChange'); + const openAnimationSpy = spyOn(node, 'playOpenAnimation'); + const closeAnimationSpy = spyOn(node, 'playCloseAnimation'); + const mockObj = jasmine.createSpyObj('mockElement', ['focus']); + const ingArgs = { + owner: mockTree, + cancel: false, + node + }; + const edArgs = { + owner: mockTree, + node + }; + (node as any).childrenContainer = mockObj; + expect(mockTreeService.collapse).not.toHaveBeenCalled(); + expect(mockTreeService.expand).not.toHaveBeenCalled(); + expect(mockTreeService.collapsing).not.toHaveBeenCalled(); + expect(openAnimationSpy).not.toHaveBeenCalled(); + expect(closeAnimationSpy).not.toHaveBeenCalled(); + expect(mockCdr.markForCheck).not.toHaveBeenCalled(); + expect(mockTreeService.collapsing).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanding.emit).not.toHaveBeenCalledWith(); + expect(mockTree.nodeCollapsing.emit).not.toHaveBeenCalledWith(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalledWith(); + expect(mockTree.nodeCollapsed.emit).not.toHaveBeenCalledWith(); + expect(emitSpy).not.toHaveBeenCalled(); + node.ngOnInit(); + node.expand(); + expect(openAnimationSpy).toHaveBeenCalledWith(mockObj); + expect(openAnimationSpy).toHaveBeenCalledTimes(1); + expect(mockTree.nodeExpanded.emit).toHaveBeenCalledTimes(0); + expect(mockTree.nodeExpanding.emit).toHaveBeenCalledWith(ingArgs); + expect(mockTreeService.expand).toHaveBeenCalledWith(node, true); + expect(mockTreeService.expand).toHaveBeenCalledTimes(1); + node.openAnimationDone.emit(); + expect(mockTree.nodeExpanded.emit).toHaveBeenCalledTimes(1); + expect(mockTree.nodeExpanded.emit).toHaveBeenCalledWith(edArgs); + node.collapse(); + expect(closeAnimationSpy).toHaveBeenCalledWith(mockObj); + expect(closeAnimationSpy).toHaveBeenCalledTimes(1); + expect(mockTree.nodeCollapsed.emit).toHaveBeenCalledTimes(0); + expect(mockTree.nodeCollapsing.emit).toHaveBeenCalledWith(ingArgs); + // collapse happens after animation finishes + expect(mockTreeService.collapse).toHaveBeenCalledTimes(0); + node.closeAnimationDone.emit(); + expect(mockTreeService.collapse).toHaveBeenCalledTimes(1); + expect(mockTreeService.collapse).toHaveBeenCalledWith(node); + expect(mockTree.nodeCollapsed.emit).toHaveBeenCalledTimes(1); + expect(mockTree.nodeCollapsed.emit).toHaveBeenCalledWith(edArgs); + spyOn(node, 'expand'); + spyOn(node, 'collapse'); + node.toggle(); + expect(node.expand).toHaveBeenCalledTimes(1); + expect(node.collapse).toHaveBeenCalledTimes(0); + spyOn(mockTreeService, 'isExpanded').and.returnValue(true); + node.toggle(); + expect(node.expand).toHaveBeenCalledTimes(1); + expect(node.collapse).toHaveBeenCalledTimes(1); + }); + it('Should properly get tree display density token', () => { + const node = new IgxTreeNodeComponent(mockTree, mockSelectionService, mockTreeService, + mockNavService, mockCdr, mockBuilder, mockElementRef, null); + expect(node.isCosy).toBeFalse(); + expect(node.isCompact).toBeFalse(); + spyOnProperty(mockTree, 'displayDensity', 'get').and.returnValue(DisplayDensity.cosy); + expect(node.isCosy).toBeTrue(); + expect(node.isCompact).toBeFalse(); + spyOnProperty(mockTree, 'displayDensity', 'get').and.returnValue(DisplayDensity.compact); + expect(node.isCosy).toBeFalse(); + expect(node.isCompact).toBeTrue(); + }); + + it('Should have correct path to node, regardless if node has parent or not', () => { + const node = new IgxTreeNodeComponent(mockTree, mockSelectionService, mockTreeService, + mockNavService, mockCdr, mockBuilder, mockElementRef, null); + expect(node.path).toEqual([node]); + const childNode = new IgxTreeNodeComponent(mockTree, mockSelectionService, mockTreeService, + mockNavService, mockCdr, mockBuilder, mockElementRef, node); + expect(childNode.path).toEqual([node, childNode]); + }); + + it('Should clear itself from selection service on destroy', () => { + const node = new IgxTreeNodeComponent(mockTree, mockSelectionService, mockTreeService, + mockNavService, mockCdr, mockBuilder, mockElementRef, null); + node.ngOnDestroy(); + expect(mockSelectionService.ensureStateOnNodeDelete).toHaveBeenCalledWith(node); + }); + }); + describe('IgxTreeService', () => { + it('Should properly register tree', () => { + const service = new IgxTreeService(); + expect((service as any).tree).toBe(undefined); + const mockTree = jasmine.createSpyObj('tree', ['findNodes']); + service.register(mockTree); + expect((service as any).tree).toBe(mockTree); + }); + it('Should keep a proper collection of expanded and collapsing nodes at all time, firing `expandedChange` when needed', () => { + const service = new IgxTreeService(); + const mockTree = jasmine.createSpyObj('tree', ['findNodes'], { + _singleBranchExpand: false, + get singleBranchExpand(): boolean { + return this._singleBranchExpand; + }, + set singleBranchExpand(val: boolean) { + this._singleBranchExpand = val; + } + }); + service.register(mockTree); + spyOn(service.expandedNodes, 'add').and.callThrough(); + spyOn(service.expandedNodes, 'delete').and.callThrough(); + spyOn(service.collapsingNodes, 'add').and.callThrough(); + spyOn(service.collapsingNodes, 'delete').and.callThrough(); + expect(service.expandedNodes.size).toBe(0); + expect(service.collapsingNodes.size).toBe(0); + const mockNode = jasmine.createSpyObj('node', ['collapse'], { + expandedChange: jasmine.createSpyObj('emitter', ['emit']) + }); + service.expand(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledWith(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(1); + expect(service.expandedNodes.add).toHaveBeenCalledWith(mockNode); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(1); + expect(mockNode.expandedChange.emit).toHaveBeenCalledWith(true); + expect(service.expandedNodes.size).toBe(1); + expect(mockNode.collapse).not.toHaveBeenCalled(); + service.expand(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(2); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(1); + expect(service.expandedNodes.size).toBe(1); + service.collapse(mockNode); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(2); + expect(mockNode.expandedChange.emit).toHaveBeenCalledWith(false); + expect(service.collapsingNodes.delete).toHaveBeenCalledWith(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(3); + expect(service.expandedNodes.delete).toHaveBeenCalledTimes(1); + expect(service.expandedNodes.delete).toHaveBeenCalledWith(mockNode); + expect(service.expandedNodes.size).toBe(0); + service.collapse(mockNode); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(2); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(4); + expect(service.expandedNodes.delete).toHaveBeenCalledTimes(2); + const mockArray = []; + for (let i = 0; i < 5; i++) { + const node = jasmine.createSpyObj('node', ['collapse'], { + _expanded: false, + get expanded() { + return this._expanded; + }, + set expanded(val: boolean) { + this._expanded = val; + } + }); + node.spyProp = spyOnProperty(node, 'expanded', 'set').and.callThrough(); + mockArray.push(node); + } + spyOn(mockTree, 'findNodes').and.returnValue(mockArray); + spyOnProperty(mockTree, 'singleBranchExpand', 'get').and.returnValue(true); + service.expand(mockNode); + mockArray.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(false); + expect(n.collapse).not.toHaveBeenCalled(); + }); + service.collapse(mockNode); + service.expand(mockNode, true); + mockArray.forEach(n => { + expect(n.collapse).toHaveBeenCalled(); + expect(n.collapse).toHaveBeenCalledTimes(1); + }); + expect(service.collapsingNodes.size).toBe(0); + service.collapsing(mockNode); + expect(service.collapsingNodes.size).toBe(1); + service.collapse(mockNode); + spyOnProperty(mockTree, 'singleBranchExpand', 'get').and.returnValue(true); + spyOn(mockTree, 'findNodes').and.returnValue(null); + service.expand(mockNode, true); + expect(mockTree.findNodes).toHaveBeenCalledWith(mockNode, (service as any).siblingComparer); + mockArray.forEach(n => { + expect(n.collapse).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + describe('Rendering Tests', () => { + let fix: ComponentFixture; + let tree: IgxTreeComponent; + beforeAll( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + IgxTreeSampleComponent,], + imports: [ + NoopAnimationsModule, + IgxTreeModule + ] + }).compileComponents(); + }) + ); + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeSampleComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + }); + + describe('General', () => { + it('Should only render node children', () => { + const treeEl: HTMLElement = fix.debugElement.queryAll(By.css(`.${TREE_ROOT_CLASS}`))[0].nativeElement; + let childNodes = treeEl.children; + expect(childNodes.length).toBe(5); + for (let i = 0; i < childNodes.length; i++) { + expect(childNodes.item(i).tagName === NODE_TAG); + } + fix.componentInstance.divChild = true; + childNodes = treeEl.children; + expect(childNodes.length).toBe(5); + for (let i = 0; i < childNodes.length; i++) { + expect(childNodes.item(i).tagName === NODE_TAG); + } + }); + it('Should not render collapsed nodes', () => { + let allNodes: DebugElement[] = fix.debugElement.queryAll(By.css(NODE_TAG)); + expect(allNodes.length).toBe(5); + tree.nodes.first.expanded = true; + fix.detectChanges(); + allNodes = fix.debugElement.queryAll(By.css(NODE_TAG)); + expect(allNodes.length).toBe(10); + const visibleNodes = tree.nodes.filter(n => allNodes.findIndex(e => e.nativeElement === n.nativeElement) > -1); + visibleNodes.forEach(n => { + expect(n.level === 0 || n.parentNode.expanded === true).toBeTruthy(); + }); + }); + + it('Should apply proper node classes depending on tree displayDenisty', () => { + pending('Test not implemented'); + }); + + it('Should properly emit state toggle events', fakeAsync(() => { + // node event spies + const collapsingSpy = spyOn(tree.nodeCollapsing, 'emit').and.callThrough(); + const expandingSpy = spyOn(tree.nodeExpanding, 'emit').and.callThrough(); + const expandedSpy = spyOn(tree.nodeExpanded, 'emit').and.callThrough(); + const collapsedSpy = spyOn(tree.nodeCollapsed, 'emit').and.callThrough(); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(expandingSpy).not.toHaveBeenCalled(); + expect(expandedSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + tree.nodes.first.expand(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(expandedSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + tick(); + fix.detectChanges(); + tick(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + tree.nodes.first.collapse(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).toHaveBeenCalledTimes(1); + expect(collapsedSpy).not.toHaveBeenCalled(); + tick(); + fix.detectChanges(); + tick(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).toHaveBeenCalledTimes(1); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + // cancel ingEvents + const unsub$ = new Subject(); + tree.nodeExpanding.pipe(takeUntil(unsub$)).subscribe(e => { + e.cancel = true; + }); + tree.nodes.first.expand(); + expect(expandingSpy).toHaveBeenCalledTimes(2); + expect(expandedSpy).toHaveBeenCalledTimes(1); + tick(); + fix.detectChanges(); + tick(); + expect(expandingSpy).toHaveBeenCalledTimes(2); + expect(expandedSpy).toHaveBeenCalledTimes(1); + unsub$.next(); + tree.nodeCollapsing.pipe(takeUntil(unsub$)).subscribe(e => { + e.cancel = true; + }); + tree.nodes.first.collapse(); + expect(collapsingSpy).toHaveBeenCalledTimes(2); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + tick(); + fix.detectChanges(); + tick(); + expect(collapsingSpy).toHaveBeenCalledTimes(2); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + unsub$.next(); + unsub$.complete(); + })); + + it('Should collapse all sibling nodes when `singleBranchExpand` is set and node is toggled', fakeAsync(() => { + pending('Causes jasmine to hang'); + tree.rootNodes.forEach(n => n.expanded = true); + fix.detectChanges(); + tree.rootNodes[0].expanded = false; + fix.detectChanges(); + expect(tree.nodes.filter(n => n.expanded).length).toBe(4); + tree.singleBranchExpand = true; + tree.rootNodes.forEach(n => { + spyOn(n.expandedChange, 'emit').and.callThrough(); + }); + const collapsingSpy = spyOn(tree.nodeCollapsing, 'emit').and.callThrough(); + const expandingSpy = spyOn(tree.nodeExpanding, 'emit').and.callThrough(); + const expandedSpy = spyOn(tree.nodeCollapsed, 'emit').and.callThrough(); + const collapsedSpy = spyOn(tree.nodeExpanded, 'emit').and.callThrough(); + // should not emit event when nodes are toggled through input + tree.rootNodes[0].expanded = true; + fix.detectChanges(); + tree.rootNodes.forEach(n => { + expect(n.expandedChange.emit).toHaveBeenCalled(); + }); + expect(expandingSpy).not.toHaveBeenCalled(); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(expandedSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + expect(tree.nodes.filter(n => n.expanded).length).toBe(1); + const expandedArgs = { + node: tree.rootNodes[1], + owner: tree + }; + const collapsedArgs = { + node: tree.rootNodes[0], + owner: tree + }; + tree.rootNodes[1].expand(); + tick(); + fix.detectChanges(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandingSpy).toHaveBeenCalledWith(Object.assign({}, expandedArgs, { cancel: false })); + expect(collapsingSpy).toHaveBeenCalledTimes(1); + expect(expandingSpy).toHaveBeenCalledWith(Object.assign({}, collapsedArgs, { cancel: false })); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledWith(expandedArgs); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + expect(collapsedSpy).toHaveBeenCalledWith(collapsedArgs); + tree.singleBranchExpand = false; + fix.detectChanges(); + const deepNode = tree.findNodes('2-1', (_id: '2-1', n: IgxTreeNodeComponent) => n.data.id === '2-1')[0]; + expect(deepNode).not.toBeNull(); + fix.componentInstance.expandToNode(deepNode); + const siblingNodes = tree.findNodes(deepNode, + (tn: IgxTreeNodeComponent, n: IgxTreeNodeComponent) => n.level === tn.level && n.parentNode === tn.parentNode + ); + expect(siblingNodes.length).toBe(5); + siblingNodes.forEach(n => n.expanded = true); + fix.detectChanges(); + expect(tree.nodes.filter(e => e.expanded).length).toBe(7); + siblingNodes[0].expanded = false; + fix.detectChanges(); + expect(tree.nodes.filter(e => e.expanded).length).toBe(6); + tree.singleBranchExpand = true; + siblingNodes[0].expanded = true; + fix.detectChanges(); + expect(tree.nodes.filter(e => e.expanded).length).toBe(3); + const nodeLevels = tree.nodes.filter(n => n.expanded).map(n => n.level); + expect(nodeLevels).toEqual([0, 1, 2]); + })); + }); + describe('ARIA', () => { + it('Should render proper roles for tree and nodes', () => { + pending('Test not implemented'); + }); + it('Should render proper label for expand/collapse indicator, depending on node state', () => { + pending('Test not implemented'); + + }); + it('Should render proper roles for nodes containing link children', () => { + pending('Test not implemented'); + + }); + }); + }); +}); +@Component({ + template: ` + + + {{ node.label }} + + {{ child.label }} + + {{ leafChild.label }} + + + +
+
+ ` +}) +class IgxTreeSampleComponent { + @ViewChild(IgxTreeComponent) + public tree: IgxTreeComponent; + + public divChild = true; + public data = createHierarchicalData(5, 3); + + public expandToNode(node: IgxTreeNodeComponent): void { + node.path.forEach(n => n.expanded = true); + } +} + +class MockDataItem { + public selected = false; + public expanded = false; + public children: MockDataItem[] = []; + constructor(public id: string, public label: string) { + } +} + +const createHierarchicalData = (siblings: number, depth: number): MockDataItem[] => { + let id = 0; + const returnArr = []; + for (let i = 0; i < siblings; i++) { + const item = new MockDataItem(`${depth}-${id}`, `Label ${depth}-${id}`); + id++; + returnArr.push(item); + if (depth > 0) { + item.children = createHierarchicalData(siblings, depth - 1); + } + } + return returnArr; +}; diff --git a/projects/igniteui-angular/src/public_api.ts b/projects/igniteui-angular/src/public_api.ts index 9f0f29b1315..ba549099b17 100644 --- a/projects/igniteui-angular/src/public_api.ts +++ b/projects/igniteui-angular/src/public_api.ts @@ -102,6 +102,7 @@ export * from './lib/date-range-picker/public_api'; export * from './lib/grids/column-actions/column-actions-base.directive'; export * from './lib/grids/column-actions/column-actions.component'; +export * from './lib/tree/public_api'; /** * Exporter services, classes, interfaces and enums diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5f062e4686c..09d40318d5f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -465,6 +465,11 @@ export class AppComponent implements OnInit { icon: 'view_column', name: 'HierarchicalGrid Add Row' }, + { + link: '/tree', + icon: 'account_tree', + name: 'Tree' + }, { link: '/treeGrid', icon: 'view_column', diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 15135dbadf0..0a69127edb9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -147,6 +147,7 @@ import { GridEventsComponent } from './grid-events/grid-events.component'; import { GridRowAPISampleComponent } from './grid-row-api/grid-row-api.sample'; import { GridUpdatesComponent } from './grid-updates-test/grid-updates.component'; import { TestInterceptorClass } from './interceptor.service'; +import { TreeSampleComponent } from './tree/tree.sample'; const components = [ ActionStripSampleComponent, @@ -243,6 +244,7 @@ const components = [ AnimationsSampleComponent, ShadowsSampleComponent, TypographySampleComponent, + TreeSampleComponent, RadioSampleComponent, TooltipSampleComponent, HierarchicalGridSampleComponent, diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index 4f38ff408dc..a08db8fd96f 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -87,6 +87,7 @@ import { MainComponent } from './grid-finjs/main.component'; import { GridEventsComponent } from './grid-events/grid-events.component'; import { GridRowAPISampleComponent } from './grid-row-api/grid-row-api.sample'; import { GridUpdatesComponent } from './grid-updates-test/grid-updates.component'; +import { TreeSampleComponent } from './tree/tree.sample'; const appRoutes = [ { @@ -347,6 +348,10 @@ const appRoutes = [ path: 'gridMasterDetail', component: GridMasterDetailSampleComponent }, + { + path: 'tree', + component: TreeSampleComponent + }, { path: 'treeGrid', component: TreeGridSampleComponent diff --git a/src/app/routing.ts b/src/app/routing.ts index c408e483b7e..d85e1a10c4a 100644 --- a/src/app/routing.ts +++ b/src/app/routing.ts @@ -118,6 +118,7 @@ import { MainComponent } from './grid-finjs/main.component'; import { GridEventsComponent } from './grid-events/grid-events.component'; import { GridRowAPISampleComponent } from './grid-row-api/grid-row-api.sample'; import { GridUpdatesComponent } from './grid-updates-test/grid-updates.component'; +import { TreeSampleComponent } from './tree/tree.sample'; const appRoutes = [ { @@ -489,6 +490,9 @@ const appRoutes = [ { path: 'gridFinJS', component: MainComponent + },{ + path: 'tree', + component: TreeSampleComponent }, { path: 'gridUpdates', diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 8a1f1bc0541..49042e26215 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -40,7 +40,8 @@ import { IgxToggleModule, IgxTooltipModule, IgxSelectModule, - IgxDateRangePickerModule + IgxDateRangePickerModule, + IgxTreeModule } from 'igniteui-angular'; @@ -79,6 +80,7 @@ const igniteModules = [ IgxSnackbarModule, IgxSwitchModule, IgxSplitterModule, + IgxTreeModule, IgxTabsModule, IgxTimePickerModule, IgxToastModule, diff --git a/src/app/tree/tree.sample.html b/src/app/tree/tree.sample.html new file mode 100644 index 00000000000..3cff9d530f6 --- /dev/null +++ b/src/app/tree/tree.sample.html @@ -0,0 +1,123 @@ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + Single Branch Expand +
+ +
+
+ + + + + + + + + + + + +
+ + + +
+
+

IgxTree (templated with icon and text)

+ + Disabled node + + Load on demand + + {{ item.CompanyName }} + + + + Disabled Link + + Link children + + + + + +
+ face + {{ node.ID }} +
+ NA_FRANCO_DETETO + + {{ child.ID }} + + {{ leafchild.ID }} + + +
+ + Link to Google + +
+
+ +
+

IgxTree 2: The Branchening

+ + + {{ node.CompanyName }} + + {{ child.CompanyName }} + + {{ leafchild.CompanyName }} + + + + +
+
+
+
diff --git a/src/app/tree/tree.sample.scss b/src/app/tree/tree.sample.scss new file mode 100644 index 00000000000..beb555ba822 --- /dev/null +++ b/src/app/tree/tree.sample.scss @@ -0,0 +1,70 @@ +.row { + display: flex; + flex-flow: row; +} + +.sample-template-center { + display: flex; + align-items: center; + + > * { + margin-right: 8px; + } +} + +.sample-scroll { + max-height: 300px; + overflow-y: auto; +} + +.sample-bottom-gap { + margin-bottom: 24px; + + > * { + margin-bottom: 16px; + } +} + +.sample-ellipsis { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + display: block; + align-items: center; +} + +.tree-container { + margin: 16px; + max-width: 350px; + + p { + margin-bottom: 8px; + font-size: 14px + } +} + +.controls { + padding: 24px; + + > * { + margin-right: 16px; + } + + .row { + border: 2px solid #d1d1d1; + padding: 12px; + margin: 12px; + } +} + +.sample-search { + margin-right: 24px; + + > * { + margin-right: 16px; + } +} + +.meduim { + width: 400px +} diff --git a/src/app/tree/tree.sample.ts b/src/app/tree/tree.sample.ts new file mode 100644 index 00000000000..12130a78cf4 --- /dev/null +++ b/src/app/tree/tree.sample.ts @@ -0,0 +1,338 @@ +import { useAnimation } from '@angular/animations'; +import { AfterViewInit, ChangeDetectorRef, Component, ViewChild, OnDestroy } from '@angular/core'; +import { + DisplayDensity, growVerIn, growVerOut, + IgxTreeNodeComponent, IgxTreeSearchResolver, IgxTreeComponent, ITreeNodeTogglingEventArgs, + ITreeNodeToggledEventArgs, ITreeNodeSelectionEvent, IgxTreeNode +} from 'igniteui-angular'; +import { Subject } from 'rxjs'; +import { cloneDeep } from 'lodash'; +import { HIERARCHICAL_SAMPLE_DATA } from '../shared/sample-data'; + +interface CompanyData { + ID: string; + CompanyName?: string; + ContactName?: string; + ContactTitle?: string; + Address?: string; + City?: string; + Region?: string; + PostalCode?: string; + Country?: string; + Phone?: string; + Fax?: string; + ChildCompanies?: CompanyData[]; + selected?: boolean; + expanded?: boolean; + disabled?: boolean; + active?: boolean; +} + +@Component({ + selector: 'app-tree-sample', + templateUrl: 'tree.sample.html', + styleUrls: ['tree.sample.scss'] +}) +export class TreeSampleComponent implements AfterViewInit, OnDestroy { + @ViewChild('tree1', { static: true }) + public tree: IgxTreeComponent; + + @ViewChild('test', { static: true }) + public testNode: IgxTreeNodeComponent; + + public selectionModes = []; + + public selectionMode = 'Cascading'; + + public animationDuration = 400; + + public density: DisplayDensity = DisplayDensity.comfortable; + + public displayDensities: { label: DisplayDensity; selectMode: DisplayDensity; selected: boolean; togglable: boolean }[] = [ + { + label: DisplayDensity.comfortable, + selectMode: DisplayDensity.comfortable, + selected: this.density === DisplayDensity.comfortable, + togglable: false + }, + { + label: DisplayDensity.cosy, + selectMode: DisplayDensity.cosy, + selected: this.density === DisplayDensity.cosy, + togglable: false + }, + { + label: DisplayDensity.compact, + selectMode: DisplayDensity.compact, + selected: this.density === DisplayDensity.compact, + togglable: false + } + ]; + + public data: CompanyData[]; + + public singleBranchExpand = false; + + public asyncItems = new Subject(); + public loadDuration = 6000; + private iteration = 0; + private addedIndex = 0; + + private initData: CompanyData[]; + + constructor(private cdr: ChangeDetectorRef) { + this.selectionModes = [ + { label: 'None', selectMode: 'None', selected: this.selectionMode === 'None', togglable: true }, + { label: 'Multiple', selectMode: 'Multiple', selected: this.selectionMode === 'Multiple', togglable: true }, + { label: 'Cascade', selectMode: 'Cascading', selected: this.selectionMode === 'Cascading', togglable: true } + ]; + this.data = cloneDeep(HIERARCHICAL_SAMPLE_DATA); + this.initData = cloneDeep(HIERARCHICAL_SAMPLE_DATA); + this.mapData(this.data); + } + + public setDummy() { + this.data = generateHierarchicalData('ChildCompanies', 3, 6, 0); + } + + public handleNodeExpanding(_event: ITreeNodeTogglingEventArgs) { + // do something w/ data + } + + public handleNodeExpanded(_event: ITreeNodeToggledEventArgs) { + // do something w/ data + } + + public handleNodeCollapsing(_event: ITreeNodeTogglingEventArgs) { + // do something w/ data + } + + public handleNodeCollapsed(_event: ITreeNodeToggledEventArgs) { + // do something w/ data + } + + + public addDataChild(key: string) { + const targetNode = this.getNodeByName(key); + if (!targetNode.data.ChildCompanies) { + targetNode.data.ChildCompanies = []; + } + const data = targetNode.data.ChildCompanies; + data.push(Object.assign({}, data[data.length - 1], + { CompanyName: `Added ${this.addedIndex++}`, selected: this.addedIndex % 2 === 0, ChildCompanies: [] })); + this.cdr.detectChanges(); + } + + public deleteLastChild(key: string) { + const targetNode = this.getNodeByName(key); + if (!targetNode.data.ChildCompanies) { + targetNode.data.ChildCompanies = []; + } + const data = targetNode.data.ChildCompanies; + data.splice(data.length - 1, 1); + } + + public deleteNodesFromParent(key: string, deleteNodes: string) { + const parent = this.getNodeByName(key); + const nodeIds = deleteNodes.split(';'); + nodeIds.forEach((nodeId) => { + const index = parent.data.ChildCompanies.findIndex(e => e.ID === nodeId); + parent.data.ChildCompanies.splice(index, 1); + }); + } + + public addSeveralNodes(key: string) { + const targetNode = this.getNodeByName(key); + if (!targetNode.data.ChildCompanies) { + targetNode.data.ChildCompanies = []; + } + const arr = [{ + ID: 'Some1', + CompanyName: 'Test 1', + selected: false, + ChildCompanies: [{ + ID: 'Some4', + CompanyName: 'Test 5', + selected: true, + }] + }, + { + ID: 'Some2', + CompanyName: 'Test 2', + selected: false + }, + { + ID: 'Some3', + CompanyName: 'Test 3', + selected: false + }]; + this.getNodeByName(key).data.ChildCompanies = arr; + this.cdr.detectChanges(); + } + + public handleRemote(node: IgxTreeNodeComponent, event: boolean) { + console.log(event); + node.loading = true; + setTimeout(() => { + const newData: CompanyData[] = []; + for (let i = 0; i < 10; i++) { + newData.push({ + ID: `Remote ${i}`, + CompanyName: `Remote ${i}` + }); + } + node.loading = false; + this.asyncItems.next(newData); + }, this.loadDuration); + } + + public ngAfterViewInit() { + this.tree.nodes.toArray().forEach(node => { + node.selectedChange.subscribe((ev) => { + // console.log(ev); + }); + }); + } + + public ngOnDestroy() { + } + + public toggleSelectionMode(args) { + // this.tree.selection = this.selectionModes[args.index].selectMode; + } + + public changeDensity(args) { + this.density = this.displayDensities[args.index].selectMode; + } + + public addItem() { + const newArray = [...this.data]; + const children = Math.floor(Math.random() * 4); + const createChildren = (count: number): CompanyData[] => { + const array = []; + for (let i = 0; i < count; i++) { + this.iteration++; + array.push({ + ID: `TEST${this.iteration}`, + CompanyName: `TEST${this.iteration}` + }); + } + return array; + }; + + this.iteration++; + newArray.push({ + ID: `TEST${this.iteration}`, + CompanyName: `TEST${this.iteration}`, + ChildCompanies: createChildren(children) + }); + this.data = newArray; + } + + public resetData() { + this.data = [...this.initData]; + } + + public get animationSettings() { + return { + openAnimation: useAnimation(growVerIn, { + params: { + duration: `${this.animationDuration}ms` + } + }), + closeAnimation: useAnimation(growVerOut, { + params: { + duration: `${this.animationDuration}ms` + } + }) + }; + } + + public selectSpecific() { + this.tree.nodes.toArray()[0].selected = true; + this.tree.nodes.toArray()[14].selected = true; + this.tree.nodes.toArray()[1].selected = true; + this.tree.nodes.toArray()[4].selected = true; + } + + public selectAll() { + this.tree.nodes.toArray().forEach(node => node.selected = true); + } + + public deselectSpecific() { + const arr = [ + this.tree.nodes.toArray()[0], + this.tree.nodes.toArray()[14], + this.tree.nodes.toArray()[1], + this.tree.nodes.toArray()[4] + ]; + this.tree.deselectAll(arr); + } + + public deselectAll() { + this.tree.deselectAll(); + } + + public changeNodeSelectionState() { + this.tree.nodes.toArray()[8].selected = !this.tree.nodes.toArray()[8].selected; + } + + public changeNodeData() { + this.tree.nodes.toArray()[8].data.selected = !this.tree.nodes.toArray()[8].data.selected; + this.cdr.detectChanges(); + } + + public nodeSelection(event: ITreeNodeSelectionEvent) { + // console.log(event); + if (event.newSelection.find(x => x.data.ID === 'igxTreeNode_1')) { + //event.newSelection = [...event.newSelection, this.tree.nodes.toArray()[0]]; + } + } + + public customSearch(term: string) { + const searchResult = this.tree.findNodes(term, this.containsComparer); + // console.log(searchResult); + return searchResult; + } + + public activeNodeChanged(_event: IgxTreeNode) { + // active node changed + } + + public keydown(_event: KeyboardEvent) { + // console.log(evt); + } + + private mapData(data: any[]) { + data.forEach(x => { + x.selected = false; + if (x.hasOwnProperty('ChildCompanies') && x.ChildCompanies.length) { + this.mapData(x.ChildCompanies); + } + }); + } + + private containsComparer: IgxTreeSearchResolver = + (term: any, node: IgxTreeNodeComponent) => node.data?.ID?.toLowerCase()?.indexOf(term.toLowerCase()) > -1; + + private getNodeByName(key: string) { + return this.tree.findNodes(key, (_term: string, n: IgxTreeNodeComponent) => n.data?.ID === _term)[0]; + } +} + + +const generateHierarchicalData = (childKey: string, level = 7, children = 6, iter = 0): any[] => { + const returnArray = []; + if (level === 0) { + return returnArray; + } + for (let i = 0; i < children; i++) { + // create Root member + iter++; + returnArray.push({ + ID: `Dummy${iter}`, CompanyName: `Dummy-${iter}`, + [childKey]: generateHierarchicalData(childKey, children, level - 1) + }); + } + return returnArray; +};