diff --git a/package.json b/package.json index 9a4790076..2b60c4f2d 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,7 @@ "server-dev:toggle": "npm run server-dev -- --env.component toggle", "server-dev:theme-picker": "npm run server-dev -- --env.component theme-picker", "server-dev:tree": "npm run server-dev -- --env.component tree", + "server-dev:tree-select": "npm run server-dev -- --env.component tree-select", "server-dev:typography": "npm run server-dev -- --env.component typography", "server-dev:tooltip": "npm run server-dev -- --env.component tooltip", "server-dev:timepicker": "npm run server-dev -- --env.component timepicker" diff --git a/src/cdk/a11y/key-manager/activedescendant-key-manager.ts b/src/cdk/a11y/key-manager/activedescendant-key-manager.ts index 5d9996b5a..110108306 100644 --- a/src/cdk/a11y/key-manager/activedescendant-key-manager.ts +++ b/src/cdk/a11y/key-manager/activedescendant-key-manager.ts @@ -1,5 +1,5 @@ -import { ListKeyManager, IListKeyManagerOption } from './list-key-manager'; +import { ListKeyManager, ListKeyManagerOption } from './list-key-manager'; /** @@ -7,7 +7,7 @@ import { ListKeyManager, IListKeyManagerOption } from './list-key-manager'; * Each item must know how to style itself as active or inactive and whether or not it is * currently disabled. */ -export interface IHighlightable extends IListKeyManagerOption { +export interface Highlightable extends ListKeyManagerOption { // Applies the styles for an active item to this item. setActiveStyles(): void; @@ -15,7 +15,7 @@ export interface IHighlightable extends IListKeyManagerOption { setInactiveStyles(): void; } -export class ActiveDescendantKeyManager extends ListKeyManager { +export class ActiveDescendantKeyManager extends ListKeyManager { /** * Sets the active item to the item at the specified index and adds the diff --git a/src/cdk/a11y/key-manager/focus-key-manager.ts b/src/cdk/a11y/key-manager/focus-key-manager.ts index 5dc9b8f0f..5872853ca 100644 --- a/src/cdk/a11y/key-manager/focus-key-manager.ts +++ b/src/cdk/a11y/key-manager/focus-key-manager.ts @@ -1,6 +1,6 @@ import { FocusOrigin } from '../focus-monitor/focus-monitor'; -import { ListKeyManager, IListKeyManagerOption } from './list-key-manager'; +import { ListKeyManager, ListKeyManagerOption } from './list-key-manager'; /** @@ -8,7 +8,7 @@ import { ListKeyManager, IListKeyManagerOption } from './list-key-manager'; * Each item must know how to focus itself, whether or not it is currently disabled * and be able to supply it's label. */ -export interface IFocusableOption extends IListKeyManagerOption { +export interface IFocusableOption extends ListKeyManagerOption { // Focuses the `FocusableOption`. */ focus(origin?: FocusOrigin): void; } diff --git a/src/cdk/a11y/key-manager/list-key-manager.ts b/src/cdk/a11y/key-manager/list-key-manager.ts index ee0843fd1..ea7a55b20 100644 --- a/src/cdk/a11y/key-manager/list-key-manager.ts +++ b/src/cdk/a11y/key-manager/list-key-manager.ts @@ -17,7 +17,7 @@ import { // This interface is for items that can be passed to a ListKeyManager. -export interface IListKeyManagerOption { +export interface ListKeyManagerOption { // Whether the option is disabled. disabled?: boolean; @@ -30,7 +30,7 @@ export interface IListKeyManagerOption { * This class manages keyboard events for selectable lists. If you pass it a query list * of items, it will set the active item correctly when arrow events occur. */ -export class ListKeyManager { +export class ListKeyManager { /** * Stream that emits any time the TAB key is pressed, so components can react * when focus is shifted off of the list. @@ -61,7 +61,9 @@ export class ListKeyManager { constructor(private _items: QueryList) { if (_items instanceof QueryList) { + _items.changes.subscribe((newItems: QueryList) => { + if (this._activeItem) { const itemArray = newItems.toArray(); const newIndex = itemArray.indexOf(this._activeItem); diff --git a/src/cdk/collections/selection.ts b/src/cdk/collections/selection.ts index 0f5092b85..4c6a37c61 100644 --- a/src/cdk/collections/selection.ts +++ b/src/cdk/collections/selection.ts @@ -6,15 +6,6 @@ import { Subject } from 'rxjs'; */ export class SelectionModel { - /** Selected values. */ - get selected(): T[] { - if (!this._selected) { - this._selected = Array.from(this._selection.values()); - } - - return this._selected; - } - /** Event emitted when the value has changed. */ changed: Subject> = new Subject(); @@ -25,31 +16,38 @@ export class SelectionModel { */ onChange: Subject> = this.changed; /** Currently-selected values. */ - private _selection = new Set(); + selection = new Set(); /** Keeps track of the deselected options that haven't been emitted by the change event. */ - private _deselectedToEmit: T[] = []; + private deselectedToEmit: T[] = []; /** Keeps track of the selected options that haven't been emitted by the change event. */ - private _selectedToEmit: T[] = []; + private selectedToEmit: T[] = []; + + get selected(): T[] { + if (!this._selected) { + this._selected = Array.from(this.selection.values()); + } + + return this._selected; + } - /** Cache for the array value of the selected items. */ private _selected: T[] | null; constructor( private _multiple = false, initiallySelectedValues?: T[], - private _emitChanges = true) { - + private _emitChanges: boolean = true + ) { if (initiallySelectedValues && initiallySelectedValues.length) { if (_multiple) { - initiallySelectedValues.forEach((value) => this._markSelected(value)); + initiallySelectedValues.forEach((value) => this.markSelected(value)); } else { - this._markSelected(initiallySelectedValues[0]); + this.markSelected(initiallySelectedValues[0]); } // Clear the array in order to avoid firing the change event for preselected values. - this._selectedToEmit.length = 0; + this.selectedToEmit.length = 0; } } @@ -57,47 +55,55 @@ export class SelectionModel { * Selects a value or an array of values. */ select(...values: T[]): void { - this._verifyValueAssignment(values); - values.forEach((value) => this._markSelected(value)); - this._emitChangeEvent(); + this.verifyValueAssignment(values); + + values.forEach((value) => this.markSelected(value)); + + this.emitChangeEvent(); } /** * Deselects a value or an array of values. */ deselect(...values: T[]): void { - this._verifyValueAssignment(values); - values.forEach((value) => this._unmarkSelected(value)); - this._emitChangeEvent(); + this.verifyValueAssignment(values); + + values.forEach((value) => this.unmarkSelected(value)); + + this.emitChangeEvent(); } /** * Toggles a value between selected and deselected. */ toggle(value: T): void { - this.isSelected(value) ? this.deselect(value) : this.select(value); + if (this.isSelected(value)) { + this.deselect(value); + } else { + this.select(value); + } } /** * Clears all of the selected values. */ clear(): void { - this._unmarkAll(); - this._emitChangeEvent(); + this.unmarkAll(); + this.emitChangeEvent(); } /** * Determines whether a value is selected. */ isSelected(value: T): boolean { - return this._selection.has(value); + return this.selection.has(value); } /** * Determines whether the model does not have a value. */ isEmpty(): boolean { - return this._selection.size === 0; + return this.selection.size === 0; } /** @@ -124,52 +130,52 @@ export class SelectionModel { } /** Emits a change event and clears the records of selected and deselected values. */ - private _emitChangeEvent() { + private emitChangeEvent() { // Clear the selected values so they can be re-cached. this._selected = null; - if (this._selectedToEmit.length || this._deselectedToEmit.length) { + if (this.selectedToEmit.length || this.deselectedToEmit.length) { this.changed.next({ source: this, - added: this._selectedToEmit, - removed: this._deselectedToEmit + added: this.selectedToEmit, + removed: this.deselectedToEmit }); - this._deselectedToEmit = []; - this._selectedToEmit = []; + this.deselectedToEmit = []; + this.selectedToEmit = []; } } /** Selects a value. */ - private _markSelected(value: T) { + private markSelected(value: T) { if (!this.isSelected(value)) { if (!this._multiple) { - this._unmarkAll(); + this.unmarkAll(); } - this._selection.add(value); + this.selection.add(value); if (this._emitChanges) { - this._selectedToEmit.push(value); + this.selectedToEmit.push(value); } } } /** Deselects a value. */ - private _unmarkSelected(value: T) { + private unmarkSelected(value: T) { if (this.isSelected(value)) { - this._selection.delete(value); + this.selection.delete(value); if (this._emitChanges) { - this._deselectedToEmit.push(value); + this.deselectedToEmit.push(value); } } } /** Clears out the selected values. */ - private _unmarkAll() { + private unmarkAll() { if (!this.isEmpty()) { - this._selection.forEach((value) => this._unmarkSelected(value)); + this.selection.forEach((value) => this.unmarkSelected(value)); } } @@ -177,7 +183,7 @@ export class SelectionModel { * Verifies the value assignment and throws an error if the specified value array is * including multiple values while the selection model is not supporting multiple values. */ - private _verifyValueAssignment(values: T[]) { + private verifyValueAssignment(values: T[]) { if (values.length > 1 && !this._multiple) { throw getMultipleValuesInSingleSelectionError(); } diff --git a/src/cdk/overlay/overlay-directives.spec.ts b/src/cdk/overlay/overlay-directives.spec.ts index 5cfc456fa..71eea8b6a 100644 --- a/src/cdk/overlay/overlay-directives.spec.ts +++ b/src/cdk/overlay/overlay-directives.spec.ts @@ -234,7 +234,7 @@ describe('Overlay directives', () => { const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; - expect(backdrop.classList).toContain('mat-test-class'); + expect(backdrop.classList).toContain('mc-test-class'); }); it('should set the offsetX', () => { @@ -380,7 +380,7 @@ describe('Overlay directives', () => { [cdkConnectedOverlayFlexibleDimensions]="flexibleDimensions" [cdkConnectedOverlayGrowAfterOpen]="growAfterOpen" [cdkConnectedOverlayPush]="push" - cdkConnectedOverlayBackdropClass="mat-test-class" + cdkConnectedOverlayBackdropClass="mc-test-class" (backdropClick)="backdropClickHandler($event)" [cdkConnectedOverlayOffsetX]="offsetX" [cdkConnectedOverlayOffsetY]="offsetY" diff --git a/src/cdk/overlay/overlay-directives.ts b/src/cdk/overlay/overlay-directives.ts index e63bd1bac..bcc348db4 100644 --- a/src/cdk/overlay/overlay-directives.ts +++ b/src/cdk/overlay/overlay-directives.ts @@ -235,7 +235,8 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { templateRef: TemplateRef, viewContainerRef: ViewContainerRef, @Inject(CDK_CONNECTED_OVERLAY_SCROLL_STRATEGY) private _scrollStrategy, - @Optional() private _dir: Directionality) { + @Optional() private _dir: Directionality + ) { this._templatePortal = new TemplatePortal(templateRef, viewContainerRef); } diff --git a/src/cdk/overlay/overlay.ts b/src/cdk/overlay/overlay.ts index e0209ea6f..6a29b49a1 100644 --- a/src/cdk/overlay/overlay.ts +++ b/src/cdk/overlay/overlay.ts @@ -46,8 +46,8 @@ export class Overlay { private _injector: Injector, private _ngZone: NgZone, @Inject(DOCUMENT) private _document: any, - private _directionality: Directionality) { - } + private _directionality: Directionality + ) {} /** * Creates an overlay. diff --git a/src/cdk/testing/dispatch-events.ts b/src/cdk/testing/dispatch-events.ts index 1548e0b02..044eadc88 100644 --- a/src/cdk/testing/dispatch-events.ts +++ b/src/cdk/testing/dispatch-events.ts @@ -24,7 +24,7 @@ export function dispatchFakeEvent(node: Node | Window, type: string, canBubble?: export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number, target?: Element, shiftKey = false, ctrlKey = false, altKey = false): KeyboardEvent { - const event = createKeyboardEvent(type, keyCode, target, undefined, shiftKey, ctrlKey, altKey); + const event = createKeyboardEvent(type, keyCode, target, undefined, shiftKey, ctrlKey, altKey); return dispatchEvent(node, event) as KeyboardEvent; } diff --git a/src/cdk/tree/control/base-tree-control.ts b/src/cdk/tree/control/base-tree-control.ts index aff4b93c9..b02bcf7d3 100644 --- a/src/cdk/tree/control/base-tree-control.ts +++ b/src/cdk/tree/control/base-tree-control.ts @@ -5,6 +5,8 @@ import { ITreeControl } from './tree-control'; /** Base tree control. It has basic toggle/expand/collapse operations on a single data node. */ +// todo здесь явно ошибка проектирования, абстрактный класс реализует функционал +/* tslint:disable-next-line:naming-convention */ export abstract class BaseTreeControl implements ITreeControl { /** Saved data node for `expandAll` action. */ diff --git a/src/cdk/tree/control/flat-tree-control.spec.ts b/src/cdk/tree/control/flat-tree-control.spec.ts index 5808a41f7..f605289a2 100644 --- a/src/cdk/tree/control/flat-tree-control.spec.ts +++ b/src/cdk/tree/control/flat-tree-control.spec.ts @@ -1,188 +1,189 @@ -import {FlatTreeControl} from './flat-tree-control'; - -describe('CdkFlatTreeControl', () => { - let treeControl: FlatTreeControl; - let getLevel = (node: TestData) => node.level; - let isExpandable = (node: TestData) => node.children && node.children.length > 0; - - beforeEach(() => { - treeControl = new FlatTreeControl(getLevel, isExpandable); - }); - - describe('base tree control actions', () => { - it('should be able to expand and collapse dataNodes', () => { - const nodes = generateData(10, 4); - const secondNode = nodes[1]; - const sixthNode = nodes[5]; - treeControl.dataNodes = nodes; - - treeControl.expand(secondNode); - - - expect(treeControl.isExpanded(secondNode)) - .toBeTruthy('Expect second node to be expanded'); - expect(treeControl.expansionModel.selected) - .toContain(secondNode, 'Expect second node in expansionModel'); - expect(treeControl.expansionModel.selected.length) - .toBe(1, 'Expect only second node in expansionModel'); - - treeControl.toggle(sixthNode); - - expect(treeControl.isExpanded(secondNode)) - .toBeTruthy('Expect second node to stay expanded'); - expect(treeControl.isExpanded(sixthNode)) - .toBeTruthy('Expect sixth node to be expanded'); - expect(treeControl.expansionModel.selected) - .toContain(sixthNode, 'Expect sixth node in expansionModel'); - expect(treeControl.expansionModel.selected) - .toContain(secondNode, 'Expect second node in expansionModel'); - expect(treeControl.expansionModel.selected.length) - .toBe(2, 'Expect two dataNodes in expansionModel'); - - treeControl.collapse(secondNode); - - expect(treeControl.isExpanded(secondNode)) - .toBeFalsy('Expect second node to be collapsed'); - expect(treeControl.expansionModel.selected.length) - .toBe(1, 'Expect one node in expansionModel'); - expect(treeControl.isExpanded(sixthNode)).toBeTruthy('Expect sixth node to stay expanded'); - expect(treeControl.expansionModel.selected) - .toContain(sixthNode, 'Expect sixth node in expansionModel'); - }); - - it('should return correct expandable values', () => { - const nodes = generateData(10, 4); - treeControl.dataNodes = nodes; - - for (let i = 0; i < 10; i++) { - expect(treeControl.isExpandable(nodes[i])) - .toBeTruthy(`Expect node[${i}] to be expandable`); - - for (let j = 0; j < 4; j++) { - expect(treeControl.isExpandable(nodes[i].children[j])) - .toBeFalsy(`Expect node[${i}]'s child[${j}] to be not expandable`); - } - } - }); - - it('should return correct levels', () => { - const numNodes = 10; - const numChildren = 4; - const numGrandChildren = 2; - const nodes = generateData(numNodes, numChildren, numGrandChildren); - treeControl.dataNodes = nodes; - - for (let i = 0; i < numNodes; i++) { - expect(treeControl.getLevel(nodes[i])) - .toBe(1, `Expec node[${i}]'s level to be 1`); - - for (let j = 0; j < numChildren; j++) { - expect(treeControl.getLevel(nodes[i].children[j])) - .toBe(2, `Expect node[${i}]'s child[${j}] to be not expandable`); - - for (let k = 0; k < numGrandChildren; k++) { - expect(treeControl.getLevel(nodes[i].children[j].children[k])) - .toBe(3, `Expect node[${i}]'s child[${j}] to be not expandable`); - } - } - } - }); - - it('should toggle descendants correctly', () => { - const numNodes = 10; - const numChildren = 4; - const numGrandChildren = 2; - const nodes = generateData(numNodes, numChildren, numGrandChildren); - - let data = []; - flatten(nodes, data); - treeControl.dataNodes = data; - - treeControl.expandDescendants(nodes[1]); - - const expandedNodesNum = 1 + numChildren + numChildren * numGrandChildren; - expect(treeControl.expansionModel.selected.length) - .toBe(expandedNodesNum, `Expect expanded ${expandedNodesNum} nodes`); - - expect(treeControl.isExpanded(nodes[1])).toBeTruthy('Expect second node to be expanded'); - for (let i = 0; i < numChildren; i++) { - - expect(treeControl.isExpanded(nodes[1].children[i])) - .toBeTruthy(`Expect second node's children to be expanded`); - for (let j = 0; j < numGrandChildren; j++) { - expect(treeControl.isExpanded(nodes[1].children[i].children[j])) - .toBeTruthy(`Expect second node grand children to be not expanded`); - } - } - - }); - - it('should be able to expand/collapse all the dataNodes', () => { - const numNodes = 10; - const numChildren = 4; - const numGrandChildren = 2; - const nodes = generateData(numNodes, numChildren, numGrandChildren); - let data = []; - flatten(nodes, data); - treeControl.dataNodes = data; - - treeControl.expandDescendants(nodes[1]); - - treeControl.collapseAll(); - - expect(treeControl.expansionModel.selected.length).toBe(0, `Expect no expanded nodes`); - - treeControl.expandAll(); - - const totalNumber = numNodes + numNodes * numChildren - + numNodes * numChildren * numGrandChildren; - expect(treeControl.expansionModel.selected.length) - .toBe(totalNumber, `Expect ${totalNumber} expanded nodes`); - }); - }); -}); - -export class TestData { - a: string; - b: string; - c: string; - level: number; - children: TestData[]; - - constructor(a: string, b: string, c: string, level: number = 1, children: TestData[] = []) { - this.a = a; - this.b = b; - this.c = c; - this.level = level; - this.children = children; - } -} - -function generateData(dataLength: number, childLength: number, grandChildLength: number = 0) - : TestData[] { - let data = []; - let nextIndex = 0; - for (let i = 0; i < dataLength; i++) { - let children = []; - for (let j = 0; j < childLength; j++) { - let grandChildren = []; - for (let k = 0; k < grandChildLength; k++) { - grandChildren.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 3)); - } - children.push( - new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 2, grandChildren)); - } - data.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 1, children)); - } - return data; -} - -function flatten(nodes: TestData[], data: TestData[]) { - for (let node of nodes) { - data.push(node); - - if (node.children && node.children.length > 0) { - flatten(node.children, data); - } - } -} +import { FlatTreeControl } from './flat-tree-control'; + + +describe('CdkFlatTreeControl', () => { + let treeControl: FlatTreeControl; + const getLevel = (node: TestData) => node.level; + const isExpandable = (node: TestData) => node.children && node.children.length > 0; + + beforeEach(() => { + treeControl = new FlatTreeControl(getLevel, isExpandable); + }); + + describe('base tree control actions', () => { + it('should be able to expand and collapse dataNodes', () => { + const nodes = generateData(10, 4); + const secondNode = nodes[1]; + const sixthNode = nodes[5]; + treeControl.dataNodes = nodes; + + treeControl.expand(secondNode); + + + expect(treeControl.isExpanded(secondNode)) + .toBeTruthy('Expect second node to be expanded'); + expect(treeControl.expansionModel.selected) + .toContain(secondNode, 'Expect second node in expansionModel'); + expect(treeControl.expansionModel.selected.length) + .toBe(1, 'Expect only second node in expansionModel'); + + treeControl.toggle(sixthNode); + + expect(treeControl.isExpanded(secondNode)) + .toBeTruthy('Expect second node to stay expanded'); + expect(treeControl.isExpanded(sixthNode)) + .toBeTruthy('Expect sixth node to be expanded'); + expect(treeControl.expansionModel.selected) + .toContain(sixthNode, 'Expect sixth node in expansionModel'); + expect(treeControl.expansionModel.selected) + .toContain(secondNode, 'Expect second node in expansionModel'); + expect(treeControl.expansionModel.selected.length) + .toBe(2, 'Expect two dataNodes in expansionModel'); + + treeControl.collapse(secondNode); + + expect(treeControl.isExpanded(secondNode)) + .toBeFalsy('Expect second node to be collapsed'); + expect(treeControl.expansionModel.selected.length) + .toBe(1, 'Expect one node in expansionModel'); + expect(treeControl.isExpanded(sixthNode)).toBeTruthy('Expect sixth node to stay expanded'); + expect(treeControl.expansionModel.selected) + .toContain(sixthNode, 'Expect sixth node in expansionModel'); + }); + + it('should return correct expandable values', () => { + const nodes = generateData(10, 4); + treeControl.dataNodes = nodes; + + for (let i = 0; i < 10; i++) { + expect(treeControl.isExpandable(nodes[i])) + .toBeTruthy(`Expect node[${i}] to be expandable`); + + for (let j = 0; j < 4; j++) { + expect(treeControl.isExpandable(nodes[i].children[j])) + .toBeFalsy(`Expect node[${i}]'s child[${j}] to be not expandable`); + } + } + }); + + it('should return correct levels', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + treeControl.dataNodes = nodes; + + for (let i = 0; i < numNodes; i++) { + expect(treeControl.getLevel(nodes[i])) + .toBe(1, `Expec node[${i}]'s level to be 1`); + + for (let j = 0; j < numChildren; j++) { + expect(treeControl.getLevel(nodes[i].children[j])) + .toBe(2, `Expect node[${i}]'s child[${j}] to be not expandable`); + + for (let k = 0; k < numGrandChildren; k++) { + expect(treeControl.getLevel(nodes[i].children[j].children[k])) + .toBe(3, `Expect node[${i}]'s child[${j}] to be not expandable`); + } + } + } + }); + + it('should toggle descendants correctly', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + + let data = []; + flatten(nodes, data); + treeControl.dataNodes = data; + + treeControl.expandDescendants(nodes[1]); + + const expandedNodesNum = 1 + numChildren + numChildren * numGrandChildren; + expect(treeControl.expansionModel.selected.length) + .toBe(expandedNodesNum, `Expect expanded ${expandedNodesNum} nodes`); + + expect(treeControl.isExpanded(nodes[1])).toBeTruthy('Expect second node to be expanded'); + for (let i = 0; i < numChildren; i++) { + + expect(treeControl.isExpanded(nodes[1].children[i])) + .toBeTruthy(`Expect second node's children to be expanded`); + for (let j = 0; j < numGrandChildren; j++) { + expect(treeControl.isExpanded(nodes[1].children[i].children[j])) + .toBeTruthy(`Expect second node grand children to be not expanded`); + } + } + + }); + + it('should be able to expand/collapse all the dataNodes', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + let data = []; + flatten(nodes, data); + treeControl.dataNodes = data; + + treeControl.expandDescendants(nodes[1]); + + treeControl.collapseAll(); + + expect(treeControl.expansionModel.selected.length).toBe(0, `Expect no expanded nodes`); + + treeControl.expandAll(); + + const totalNumber = numNodes + numNodes * numChildren + + numNodes * numChildren * numGrandChildren; + expect(treeControl.expansionModel.selected.length) + .toBe(totalNumber, `Expect ${totalNumber} expanded nodes`); + }); + }); +}); + +export class TestData { + a: string; + b: string; + c: string; + level: number; + children: TestData[]; + + constructor(a: string, b: string, c: string, level: number = 1, children: TestData[] = []) { + this.a = a; + this.b = b; + this.c = c; + this.level = level; + this.children = children; + } +} + +function generateData(dataLength: number, childLength: number, grandChildLength: number = 0) + : TestData[] { + let data = []; + let nextIndex = 0; + for (let i = 0; i < dataLength; i++) { + let children = []; + for (let j = 0; j < childLength; j++) { + let grandChildren = []; + for (let k = 0; k < grandChildLength; k++) { + grandChildren.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 3)); + } + children.push( + new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 2, grandChildren)); + } + data.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 1, children)); + } + return data; +} + +function flatten(nodes: TestData[], data: TestData[]) { + for (let node of nodes) { + data.push(node); + + if (node.children && node.children.length > 0) { + flatten(node.children, data); + } + } +} diff --git a/src/cdk/tree/control/nested-tree-control.ts b/src/cdk/tree/control/nested-tree-control.ts index 5418a2aa7..e84a04a11 100644 --- a/src/cdk/tree/control/nested-tree-control.ts +++ b/src/cdk/tree/control/nested-tree-control.ts @@ -30,17 +30,21 @@ export class NestedTreeControl extends BaseTreeControl { const descendants = []; this._getDescendants(descendants, dataNode); - // Remove the node itself return descendants.splice(1); } /** A helper function to get descendants recursively. */ - protected _getDescendants(descendants: T[], dataNode: T): void { + // todo нужно придумать другое название и понять в чем отличие между getDescendants и _getDescendants + /* tslint:disable-next-line:naming-convention */ + private _getDescendants(descendants: T[], dataNode: T): void { descendants.push(dataNode); - this.getChildren(dataNode).pipe(take(1)).subscribe((children) => { - if (children && children.length > 0) { - children.forEach((child: T) => this._getDescendants(descendants, child)); - } - }); + + this.getChildren(dataNode) + .pipe(take(1)) + .subscribe((children) => { + if (children && children.length > 0) { + children.forEach((child: T) => this._getDescendants(descendants, child)); + } + }); } } diff --git a/src/cdk/tree/control/tree-control.ts b/src/cdk/tree/control/tree-control.ts index f009b4203..8864713d5 100644 --- a/src/cdk/tree/control/tree-control.ts +++ b/src/cdk/tree/control/tree-control.ts @@ -15,6 +15,19 @@ export interface ITreeControl { /** The expansion model */ expansionModel: SelectionModel; + /** Get depth of a given data node, return the level number. This is for flat tree node. */ + getLevel(dataNode: T): number; + + /** + * Whether the data node is expandable. Returns true if expandable. + * This is for flat tree node. + */ + isExpandable(dataNode: T): boolean; + + /** Gets a stream that emits whenever the given data node's children change. */ + getChildren(dataNode: T): Observable; + + /** Whether the data node is expanded or collapsed. Return true if it's expanded. */ isExpanded(dataNode: T): boolean; @@ -44,16 +57,4 @@ export interface ITreeControl { /** Collapse a data node and all its descendants */ collapseDescendants(dataNode: T): void; - - /** Get depth of a given data node, return the level number. This is for flat tree node. */ - readonly getLevel: (dataNode: T) => number; - - /** - * Whether the data node is expandable. Returns true if expandable. - * This is for flat tree node. - */ - readonly isExpandable: (dataNode: T) => boolean; - - /** Gets a stream that emits whenever the given data node's children change. */ - readonly getChildren: (dataNode: T) => Observable; } diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts index 7c871a534..bfe9ba11e 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -51,60 +51,56 @@ export class CdkNestedTreeNode extends CdkTreeNode implements AfterContent @ContentChildren(CdkTreeNodeOutlet) nodeOutlet: QueryList; /** The children data dataNodes of current node. They will be placed in `CdkTreeNodeOutlet`. */ - protected _children: T[]; + protected children: T[]; /** Differ used to find the changes in the data provided by the data source. */ - private _dataDiffer: IterableDiffer; + private dataDiffer: IterableDiffer; - constructor( - protected _elementRef: ElementRef, - protected _tree: CdkTree, - protected _differs: IterableDiffers - ) { - super(_elementRef, _tree); + constructor(protected elementRef: ElementRef, protected tree: CdkTree, protected differs: IterableDiffers) { + super(elementRef, tree); } ngAfterContentInit() { - this._dataDiffer = this._differs.find([]).create(this._tree.trackBy); + this.dataDiffer = this.differs.find([]).create(this.tree.trackBy); - if (!this._tree.treeControl.getChildren) { + if (!this.tree.treeControl.getChildren) { throw getTreeControlFunctionsMissingError(); } - this._tree.treeControl.getChildren(this.data) - .pipe(takeUntil(this._destroyed)) + this.tree.treeControl.getChildren(this.data) + .pipe(takeUntil(this.destroyed)) .subscribe((result) => { - this._children = result; + this.children = result; this.updateChildrenNodes(); }); this.nodeOutlet.changes - .pipe(takeUntil(this._destroyed)) + .pipe(takeUntil(this.destroyed)) .subscribe(() => this.updateChildrenNodes()); } ngOnDestroy() { - this._clear(); + this.clear(); super.ngOnDestroy(); } /** Add children dataNodes to the NodeOutlet */ protected updateChildrenNodes(): void { - if (this.nodeOutlet.length && this._children) { - this._tree.renderNodeChanges( - this._children, this._dataDiffer, this.nodeOutlet.first.viewContainer, this._data + if (this.nodeOutlet.length && this.children) { + this.tree.renderNodeChanges( + this.children, this.dataDiffer, this.nodeOutlet.first.viewContainer, this.data ); } else { // Reset the data differ if there's no children nodes displayed - this._dataDiffer.diff([]); + this.dataDiffer.diff([]); } } /** Clear the children dataNodes. */ - protected _clear(): void { + protected clear(): void { if (this.nodeOutlet && this.nodeOutlet.first) { this.nodeOutlet.first.viewContainer.clear(); - this._dataDiffer.diff([]); + this.dataDiffer.diff([]); } } } diff --git a/src/cdk/tree/outlet.ts b/src/cdk/tree/outlet.ts index 075aac70f..39ab08009 100644 --- a/src/cdk/tree/outlet.ts +++ b/src/cdk/tree/outlet.ts @@ -1,4 +1,4 @@ -import { Directive, ViewContainerRef } from '@angular/core'; +import { ChangeDetectorRef, Directive, ViewContainerRef } from '@angular/core'; /** @@ -7,5 +7,5 @@ import { Directive, ViewContainerRef } from '@angular/core'; */ @Directive({ selector: '[cdkTreeNodeOutlet]' }) export class CdkTreeNodeOutlet { - constructor(public viewContainer: ViewContainerRef) {} + constructor(public viewContainer: ViewContainerRef, public changeDetectorRef: ChangeDetectorRef) {} } diff --git a/src/cdk/tree/padding.ts b/src/cdk/tree/padding.ts index c60488616..3448e1690 100644 --- a/src/cdk/tree/padding.ts +++ b/src/cdk/tree/padding.ts @@ -1,7 +1,6 @@ import { Directive, ElementRef, Input, OnDestroy, Optional, Renderer2 } from '@angular/core'; import { Directionality } from '@ptsecurity/cdk/bidi'; -// import {coerceNumberProperty} from '@ptsecurity/cdk/coercion'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -17,13 +16,6 @@ import { CdkTree, CdkTreeNode } from './tree'; selector: '[cdkTreeNodePadding]' }) export class CdkTreeNodePadding implements OnDestroy { - /** Subject that emits when the component has been destroyed. */ - - _level: number; - _indent: number; - - private _destroyed = new Subject(); - /** The level of depth of the tree node. The padding will be `level * indent` pixels. */ @Input('cdkTreeNodePadding') get level(): number { @@ -31,11 +23,13 @@ export class CdkTreeNodePadding implements OnDestroy { } set level(value: number) { - // this._level = coerceNumberProperty(value); this._level = value; - this._setPadding(); + + this.setPadding(); } + /* tslint:disable-next-line:naming-convention */ + protected _level: number; @Input('cdkTreeNodePaddingIndent') get indent(): number { @@ -43,36 +37,40 @@ export class CdkTreeNodePadding implements OnDestroy { } set indent(value: number) { - // this._indent = coerceNumberProperty(value); this._indent = value; - this._setPadding(); + + this.setPadding(); } + /* tslint:disable-next-line:naming-convention */ + protected _indent: number; + + private destroyed = new Subject(); constructor( - protected _treeNode: CdkTreeNode, - protected _tree: CdkTree, - private _renderer: Renderer2, - private _element: ElementRef, - @Optional() private _dir: Directionality + protected treeNode: CdkTreeNode, + protected tree: CdkTree, + private renderer: Renderer2, + private element: ElementRef, + @Optional() private dir: Directionality ) { - if (this._dir) { - this._dir.change - .pipe(takeUntil(this._destroyed)) - .subscribe(() => this._setPadding()); + if (this.dir && this.dir.change) { + this.dir.change + .pipe(takeUntil(this.destroyed)) + .subscribe(() => this.setPadding()); } } ngOnDestroy() { - this._destroyed.next(); - this._destroyed.complete(); + this.destroyed.next(); + this.destroyed.complete(); } /** The padding indent value for the tree node. Returns a string with px numbers if not null. */ - _paddingIndent(): string | null { - const nodeLevel = (this._treeNode.data && this._tree.treeControl.getLevel) - ? this._tree.treeControl.getLevel(this._treeNode.data) + protected paddingIndent(): string | null { + const nodeLevel = (this.treeNode.data && this.tree.treeControl.getLevel) + ? this.tree.treeControl.getLevel(this.treeNode.data) : null; const level = this._level || nodeLevel; @@ -80,10 +78,10 @@ export class CdkTreeNodePadding implements OnDestroy { return level ? `${(level * this._indent) + 12}px` : '12px'; } - _setPadding() { - const padding = this._paddingIndent(); - const paddingProp = this._dir && this._dir.value === 'rtl' ? 'paddingRight' : 'paddingLeft'; + protected setPadding() { + const padding = this.paddingIndent(); + const paddingProp = this.dir && this.dir.value === 'rtl' ? 'paddingRight' : 'paddingLeft'; - this._renderer.setStyle(this._element.nativeElement, paddingProp, padding); + this.renderer.setStyle(this.element.nativeElement, paddingProp, padding); } } diff --git a/src/cdk/tree/toggle.ts b/src/cdk/tree/toggle.ts index d2e4aef16..b0794be83 100644 --- a/src/cdk/tree/toggle.ts +++ b/src/cdk/tree/toggle.ts @@ -1,40 +1,28 @@ -import { - Directive, - Input -} from '@angular/core'; +import { Directive, Input } from '@angular/core'; import { CdkTree, CdkTreeNode } from './tree'; -/** - * Node toggle to expand/collapse the node. - */ @Directive({ selector: '[cdkTreeNodeToggle]', host: { - '(click)': '_toggle($event)' + '(click)': 'toggle($event)' } }) export class CdkTreeNodeToggle { - /** Whether expand/collapse the node recursively. */ @Input('cdkTreeNodeToggleRecursive') - get recursive(): boolean { - return this._recursive; - } + get recursive(): boolean { return this._recursive; } - set recursive(value: boolean) { - this._recursive = value; - } + set recursive(value: boolean) { this._recursive = value; } - // set recursive(value: boolean) { this._recursive = toBoolean(value); } - protected _recursive = false; + private _recursive = false; - constructor(protected _tree: CdkTree, protected _treeNode: CdkTreeNode) {} + constructor(protected tree: CdkTree, protected treeNode: CdkTreeNode) {} - _toggle(event: Event): void { + toggle(event: Event): void { this.recursive - ? this._tree.treeControl.toggleDescendants(this._treeNode.data) - : this._tree.treeControl.toggle(this._treeNode.data); + ? this.tree.treeControl.toggleDescendants(this.treeNode.data) + : this.tree.treeControl.toggle(this.treeNode.data); event.stopPropagation(); } diff --git a/src/cdk/tree/tree.module.ts b/src/cdk/tree/tree.module.ts index 85504b525..4fc98d10d 100644 --- a/src/cdk/tree/tree.module.ts +++ b/src/cdk/tree/tree.module.ts @@ -24,6 +24,6 @@ const EXPORTED_DECLARATIONS = [ imports: [CommonModule], exports: EXPORTED_DECLARATIONS, declarations: EXPORTED_DECLARATIONS, - providers: [FocusMonitor, CdkTreeNodeDef] + providers: [FocusMonitor] }) export class CdkTreeModule {} diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index ad2bc2953..4bd8e8eaa 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -37,90 +37,6 @@ import { } from './tree-errors'; -/** - * Tree node for CdkTree. It contains the data in the tree node. - */ -@Directive({ - selector: 'cdk-tree-node', - exportAs: 'cdkTreeNode', - host: { - '[attr.aria-expanded]': 'isExpanded', - '[attr.aria-level]': 'role === "treeitem" ? level : null', - '[attr.role]': 'role', - class: 'cdk-tree-node' - } -}) -export class CdkTreeNode implements IFocusableOption, OnDestroy { - /** - * The most recently created `CdkTreeNode`. We save it in static variable so we can retrieve it - * in `CdkTree` and set the data to it. - */ - static mostRecentTreeNode: CdkTreeNode | null = null; - - /** - * The role of the node should be 'group' if it's an internal node, - * and 'treeitem' if it's a leaf node. - */ - @Input() role: 'treeitem' | 'group' = 'treeitem'; - - /** Subject that emits when the component has been destroyed. */ - protected _destroyed = new Subject(); - - protected _data: T; - - /** The tree node's data. */ - get data(): T { - return this._data; - } - - set data(value: T) { - this._data = value; - this._setRoleFromData(); - } - - get isExpanded(): boolean { - return this._tree.treeControl.isExpanded(this._data); - } - - get level(): number { - return this._tree.treeControl.getLevel ? this._tree.treeControl.getLevel(this._data) : 0; - } - - constructor( - protected _elementRef: ElementRef, - @Inject(forwardRef(() => CdkTree)) - protected _tree: CdkTree - ) { - CdkTreeNode.mostRecentTreeNode = this as CdkTreeNode; - } - - ngOnDestroy() { - this._destroyed.next(); - this._destroyed.complete(); - } - - /** Focuses the dropdown item. Implements for IFocusableOption. */ - focus(): void { - this._elementRef.nativeElement.focus(); - } - - private _setRoleFromData(): void { - if (this._tree.treeControl.isExpandable) { - this.role = this._tree.treeControl.isExpandable(this._data) ? 'group' : 'treeitem'; - } else { - if (!this._tree.treeControl.getChildren) { - throw getTreeControlFunctionsMissingError(); - } - - this._tree.treeControl.getChildren(this._data).pipe(takeUntil(this._destroyed)) - .subscribe((children) => { - this.role = children && children.length ? 'group' : 'treeitem'; - }); - } - } -} - - /** * CDK tree component that connects with a data source to retrieve data of type `T` and renders * dataNodes with hierarchy. Updates the dataNodes when new data is provided by the data source. @@ -150,10 +66,10 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes @Input() trackBy: TrackByFunction; // Outlets within the tree's template where the dataNodes will be inserted. - @ViewChild(CdkTreeNodeOutlet) _nodeOutlet: CdkTreeNodeOutlet; + @ViewChild(CdkTreeNodeOutlet) nodeOutlet: CdkTreeNodeOutlet; /** The tree node template for the tree */ - @ContentChildren(CdkTreeNodeDef) _nodeDefs: QueryList>; + @ContentChildren(CdkTreeNodeDef) nodeDefs: QueryList>; // TODO(tinayuangao): Setup a listener for scrolling, emit the calculated view to viewChange. // Remove the MAX_VALUE in viewChange @@ -161,24 +77,23 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes * Stream containing the latest information on what rows are being displayed on screen. * Can be used by the data source to as a heuristic of what data should be provided. */ - viewChange = - new BehaviorSubject<{ start: number, end: number }>({ start: 0, end: Number.MAX_VALUE }); + viewChange = new BehaviorSubject<{ start: number; end: number }>({ start: 0, end: Number.MAX_VALUE }); /** Differ used to find the changes in the data provided by the data source. */ - protected _dataDiffer: IterableDiffer; + protected dataDiffer: IterableDiffer; /** Subject that emits when the component has been destroyed. */ - private _onDestroy = new Subject(); + private onDestroy = new Subject(); /** Stores the node definition that does not have a when predicate. */ - private _defaultNodeDef: CdkTreeNodeDef | null; + private defaultNodeDef: CdkTreeNodeDef | null; /** Data subscription */ - private _dataSubscription: Subscription | null; + private dataSubscription: Subscription | null; /** Level of nodes */ - private _levels: Map = new Map(); + private levels: Map = new Map(); /** * Provides a stream containing the latest data array to render. Influenced by the tree's @@ -192,19 +107,16 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes set dataSource(dataSource: DataSource | Observable | T[]) { if (this._dataSource !== dataSource) { - this._switchDataSource(dataSource); + this.switchDataSource(dataSource); } } private _dataSource: DataSource | Observable | T[]; - constructor( - private _differs: IterableDiffers, - private _changeDetectorRef: ChangeDetectorRef - ) {} + constructor(protected differs: IterableDiffers, protected changeDetectorRef: ChangeDetectorRef) {} ngOnInit() { - this._dataDiffer = this._differs.find([]).create(this.trackBy); + this.dataDiffer = this.differs.find([]).create(this.trackBy); if (!this.treeControl) { throw getTreeControlMissingError(); @@ -212,38 +124,40 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes } ngOnDestroy() { - this._nodeOutlet.viewContainer.clear(); + this.nodeOutlet.viewContainer.clear(); - this._onDestroy.next(); - this._onDestroy.complete(); + this.onDestroy.next(); + this.onDestroy.complete(); - if (this._dataSource && typeof (this._dataSource as DataSource).disconnect === 'function') { + // tslint:disable-next-line:no-unbound-method + if (this._dataSource && typeof (this.dataSource as DataSource).disconnect === 'function') { (this.dataSource as DataSource).disconnect(this); } - if (this._dataSubscription) { - this._dataSubscription.unsubscribe(); - this._dataSubscription = null; + if (this.dataSubscription) { + this.dataSubscription.unsubscribe(); + this.dataSubscription = null; } } ngAfterContentChecked() { - const defaultNodeDefs = this._nodeDefs.filter((def) => !def.when); + const defaultNodeDefs = this.nodeDefs.filter((def) => !def.when); + if (defaultNodeDefs.length > 1) { throw getTreeMultipleDefaultNodeDefsError(); } - this._defaultNodeDef = defaultNodeDefs[0]; + this.defaultNodeDef = defaultNodeDefs[0]; - if (this.dataSource && this._nodeDefs && !this._dataSubscription) { - this._observeRenderChanges(); + if (this.dataSource && this.nodeDefs && !this.dataSubscription) { + this.observeRenderChanges(); } } /** Check for changes made in the data and render each change (node added/removed/moved). */ renderNodeChanges( data: T[], - dataDiffer: IterableDiffer = this._dataDiffer, - viewContainer: ViewContainerRef = this._nodeOutlet.viewContainer, + dataDiffer: IterableDiffer = this.dataDiffer, + viewContainer: ViewContainerRef = this.nodeOutlet.viewContainer, parentData?: T ) { const changes = dataDiffer.diff(data); @@ -257,14 +171,14 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes this.insertNode(data[currentIndex!], currentIndex!, viewContainer, parentData); } else if (currentIndex == null) { viewContainer.remove(adjustedPreviousIndex!); - this._levels.delete(item.item); + this.levels.delete(item.item); } else { const view = viewContainer.get(adjustedPreviousIndex!); viewContainer.move(view!, currentIndex); } }); - this._changeDetectorRef.detectChanges(); + this.changeDetectorRef.detectChanges(); } /** @@ -273,11 +187,10 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes * predicate that returns true with the data. If none return true, return the default node * definition. */ - _getNodeDef(data: T, i: number): CdkTreeNodeDef { - if (this._nodeDefs.length === 1) { return this._nodeDefs.first; } + getNodeDef(data: T, i: number): CdkTreeNodeDef { + if (this.nodeDefs.length === 1) { return this.nodeDefs.first; } - const nodeDef = - this._nodeDefs.find((def) => def.when && def.when(i, data)) || this._defaultNodeDef; + const nodeDef = this.nodeDefs.find((def) => def.when && def.when(i, data)) || this.defaultNodeDef; if (!nodeDef) { throw getTreeMissingMatchingNodeDefError(); } @@ -289,7 +202,7 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes * within the data node view container. */ insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef, parentData?: T) { - const node = this._getNodeDef(nodeData, index); + const node = this.getNodeDef(nodeData, index); // Node context that will be provided to created embedded view const context = new CdkTreeNodeOutletContext(nodeData); @@ -298,16 +211,17 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes // Otherwise, use the level of parent node. if (this.treeControl.getLevel) { context.level = this.treeControl.getLevel(nodeData); - } else if (typeof parentData !== 'undefined' && this._levels.has(parentData)) { - context.level = this._levels.get(parentData)! + 1; + /* tslint:disable-next-line:no-typeof-undefined */ + } else if (typeof parentData !== 'undefined' && this.levels.has(parentData)) { + context.level = this.levels.get(parentData)! + 1; } else { context.level = 0; } - this._levels.set(nodeData, context.level); + this.levels.set(nodeData, context.level); // Use default tree nodeOutlet, or nested node's nodeOutlet - const container = viewContainer ? viewContainer : this._nodeOutlet.viewContainer; + const container = viewContainer ? viewContainer : this.nodeOutlet.viewContainer; container.createEmbeddedView(node.template, context, index); // Set the data to just created `CdkTreeNode`. @@ -319,11 +233,12 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes } /** Set up a subscription for the data provided by the data source. */ - private _observeRenderChanges() { + private observeRenderChanges() { let dataStream: Observable | undefined; // Cannot use `instanceof DataSource` since the data source could be a literal with // `connect` function and may not extends DataSource. + // tslint:disable-next-line:no-unbound-method if (typeof (this._dataSource as DataSource).connect === 'function') { dataStream = (this._dataSource as DataSource).connect(this); } else if (this._dataSource instanceof Observable) { @@ -333,8 +248,8 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes } if (dataStream) { - this._dataSubscription = dataStream - .pipe(takeUntil(this._onDestroy)) + this.dataSubscription = dataStream + .pipe(takeUntil(this.onDestroy)) .subscribe((data) => this.renderNodeChanges(data)); } else { throw getTreeNoValidDataSourceError(); @@ -346,21 +261,99 @@ export class CdkTree implements AfterContentChecked, ICollectionViewer, OnDes * render change subscription if one exists. If the data source is null, interpret this by * clearing the node outlet. Otherwise start listening for new data. */ - private _switchDataSource(dataSource: DataSource | Observable | T[]) { + private switchDataSource(dataSource: DataSource | Observable | T[]) { + // tslint:disable-next-line:no-unbound-method if (this._dataSource && typeof (this._dataSource as DataSource).disconnect === 'function') { (this.dataSource as DataSource).disconnect(this); } - if (this._dataSubscription) { - this._dataSubscription.unsubscribe(); - this._dataSubscription = null; + if (this.dataSubscription) { + this.dataSubscription.unsubscribe(); + this.dataSubscription = null; } // Remove the all dataNodes if there is now no data source - if (!dataSource) { this._nodeOutlet.viewContainer.clear(); } + if (!dataSource) { this.nodeOutlet.viewContainer.clear(); } this._dataSource = dataSource; - if (this._nodeDefs) { this._observeRenderChanges(); } + if (this.nodeDefs) { this.observeRenderChanges(); } + } +} + +/** + * Tree node for CdkTree. It contains the data in the tree node. + */ +@Directive({ + selector: 'cdk-tree-node', + exportAs: 'cdkTreeNode', + host: { + class: 'cdk-tree-node', + + '[attr.aria-expanded]': 'isExpanded', + '[attr.aria-level]': 'role === "treeitem" ? level : null', + '[attr.role]': 'role' + } +}) +export class CdkTreeNode implements IFocusableOption, OnDestroy { + /** + * The most recently created `CdkTreeNode`. We save it in static variable so we can retrieve it + * in `CdkTree` and set the data to it. + */ + static mostRecentTreeNode: CdkTreeNode | null = null; + + @Input() role: 'treeitem' | 'group' = 'treeitem'; + + protected destroyed = new Subject(); + + get data(): T { + return this._data; + } + + set data(value: T) { + this._data = value; + + this.setRoleFromData(); + } + + private _data: T; + + get isExpanded(): boolean { + return this.tree.treeControl.isExpanded(this._data); + } + + get level(): number { + return this.tree.treeControl.getLevel ? this.tree.treeControl.getLevel(this._data) : 0; + } + + constructor( + protected elementRef: ElementRef, + @Inject(forwardRef(() => CdkTree)) protected tree: CdkTree + ) { + CdkTreeNode.mostRecentTreeNode = this as CdkTreeNode; + } + + ngOnDestroy() { + this.destroyed.next(); + this.destroyed.complete(); + } + + focus(): void { + this.elementRef.nativeElement.focus(); + } + + private setRoleFromData(): void { + if (this.tree.treeControl.isExpandable) { + this.role = this.tree.treeControl.isExpandable(this._data) ? 'group' : 'treeitem'; + } else { + if (!this.tree.treeControl.getChildren) { + throw getTreeControlFunctionsMissingError(); + } + + this.tree.treeControl.getChildren(this._data).pipe(takeUntil(this.destroyed)) + .subscribe((children) => { + this.role = children && children.length ? 'group' : 'treeitem'; + }); + } } } diff --git a/src/lib-dev/all/template.html b/src/lib-dev/all/template.html index f7f92ce67..66727d643 100644 --- a/src/lib-dev/all/template.html +++ b/src/lib-dev/all/template.html @@ -1107,12 +1107,12 @@

Tree

[dataSource]="dataSource" [treeControl]="treeControl"> - {{ node.name }} + {{ node.name }} - - + + {{ node.name }} : {{ node.type }} - + diff --git a/src/lib-dev/select/styles.scss b/src/lib-dev/select/styles.scss index 099d77aae..3ae0d6df0 100644 --- a/src/lib-dev/select/styles.scss +++ b/src/lib-dev/select/styles.scss @@ -1,7 +1,7 @@ @import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; -//@import '../../lib/core/theming/prebuilt/default-theme'; -@import '../../lib/core/theming/prebuilt/dark-theme'; +@import '../../lib/core/theming/prebuilt/default-theme'; +//@import '../../lib/core/theming/prebuilt/dark-theme'; .dev-container { diff --git a/src/lib-dev/tree-select/module.ts b/src/lib-dev/tree-select/module.ts new file mode 100644 index 000000000..27daa5fa8 --- /dev/null +++ b/src/lib-dev/tree-select/module.ts @@ -0,0 +1,213 @@ +import { Component, Injectable, NgModule, ViewEncapsulation } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CdkTreeModule, FlatTreeControl } from '@ptsecurity/cdk/tree'; +import { McButtonModule } from '@ptsecurity/mosaic/button'; +import { McFormFieldModule } from '@ptsecurity/mosaic/form-field'; +import { McIconModule } from '@ptsecurity/mosaic/icon'; +import { McInputModule } from '@ptsecurity/mosaic/input'; +import { McTreeFlatDataSource, McTreeFlattener, McTreeModule } from '@ptsecurity/mosaic/tree'; + +import { McTreeSelectChange, McTreeSelectModule } from '@ptsecurity/mosaic/tree-select'; +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; + + +export class FileNode { + children: FileNode[]; + name: string; + type: any; +} + +/** Flat node with expandable and level information */ +export class FileFlatNode { + name: string; + type: any; + level: number; + expandable: boolean; +} + +export const TREE_DATA = ` + { + "rootNode_1": "app", + "Pictures": { + "Sun": "png", + "Woods": "jpg", + "Photo Booth Library": { + "Contents": "dir", + "Pictures": "dir" + } + }, + "Documents": { + "angular": { + "src": { + "core": "ts", + "compiler": "ts" + } + }, + "material2": { + "src": { + "button": "ts", + "checkbox": "ts", + "input": "ts" + } + } + }, + "Downloads": { + "Tutorial": "html", + "November": "pdf", + "October": "pdf" + }, + "Applications": { + "Chrome": "app", + "Calendar": "app", + "Webstorm": "app" + } +}`; + +@Injectable() +export class FileDatabase { + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): FileNode[] { return this.dataChange.value; } + + constructor() { + this.initialize(); + } + + initialize() { + // Parse the string to json object. + const dataObject = JSON.parse(TREE_DATA); + + // Build the tree nodes from Json object. The result is a list of `FileNode` with nested + // file node as children. + const data = this.buildFileTree(dataObject, 0); + + // Notify the change. + this.dataChange.next(data); + } + + /** + * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. + * The return value is the list of `FileNode`. + */ + buildFileTree(value: any, level: number): FileNode[] { + const data: any[] = []; + + for (const k of Object.keys(value)) { + const v = value[k]; + const node = new FileNode(); + + node.name = `${k}`; + + if (v === null || v === undefined) { + // no action + } else if (typeof v === 'object') { + node.children = this.buildFileTree(v, level + 1); + } else { + node.type = v; + } + + data.push(node); + } + + return data; + } +} + + +@Component({ + selector: 'app', + template: require('./template.html'), + styleUrls: ['./styles.scss'], + encapsulation: ViewEncapsulation.None, + providers: [FileDatabase] +}) +export class DemoComponent { + control = new FormControl('rootNode_1'); + + select: any; + + treeControl: FlatTreeControl; + treeFlattener: McTreeFlattener; + + dataSource: McTreeFlatDataSource; + + singleSelected = 'Normal'; + multipleSelected = ['Normal', 'Hovered', 'Selected', 'Selected1']; + + singleSelectFormControl = new FormControl('', Validators.required); + + multiSelectSelectFormControl = new FormControl([], Validators.pattern(/^w/)); + + constructor(database: FileDatabase) { + this.treeFlattener = new McTreeFlattener( + this.transformer, this.getLevel, this.isExpandable, this.getChildren + ); + + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => { + this.dataSource.data = data; + }); + } + + transformer(node: FileNode, level: number) { + const flatNode = new FileFlatNode(); + + flatNode.name = node.name; + flatNode.type = node.type; + flatNode.level = level; + flatNode.expandable = !!node.children; + + return flatNode; + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } + + onSelectionChange($event: McTreeSelectChange) { + console.log(`onSelectionChange: ${$event.value}`); + } + + private getLevel(node: FileFlatNode) { return node.level; } + + private isExpandable(node: FileFlatNode) { return node.expandable; } + + private getChildren = (node: FileNode): Observable => { + return observableOf(node.children); + } +} + + +@NgModule({ + declarations: [ + DemoComponent + ], + imports: [ + BrowserAnimationsModule, + BrowserModule, + FormsModule, + McTreeModule, + CdkTreeModule, + McTreeSelectModule, + + McButtonModule, + McInputModule, + McFormFieldModule, + McIconModule, + ReactiveFormsModule + ], + bootstrap: [ + DemoComponent + ] +}) +export class DemoModule {} + +platformBrowserDynamic() + .bootstrapModule(DemoModule) + .catch((error) => console.error(error)); + diff --git a/src/lib-dev/tree-select/styles.scss b/src/lib-dev/tree-select/styles.scss new file mode 100644 index 000000000..3ae0d6df0 --- /dev/null +++ b/src/lib-dev/tree-select/styles.scss @@ -0,0 +1,14 @@ +@import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; + +@import '../../lib/core/theming/prebuilt/default-theme'; +//@import '../../lib/core/theming/prebuilt/dark-theme'; + + +.dev-container { + width: 300px; + height: 140px; + + border: 1px solid red; + + padding: 24px; +} diff --git a/src/lib-dev/tree-select/template.html b/src/lib-dev/tree-select/template.html new file mode 100644 index 000000000..16d2419ee --- /dev/null +++ b/src/lib-dev/tree-select/template.html @@ -0,0 +1,128 @@ +






+
{{ selected }}
+ + + + +


+ +
+ + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib-dev/tree/module.ts b/src/lib-dev/tree/module.ts index 387af56a1..af270e046 100644 --- a/src/lib-dev/tree/module.ts +++ b/src/lib-dev/tree/module.ts @@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { CdkTreeModule, FlatTreeControl, NestedTreeControl } from '@ptsecurity/cdk/tree'; +import { FlatTreeControl, NestedTreeControl } from '@ptsecurity/cdk/tree'; import { McTreeFlatDataSource, McTreeFlattener, @@ -96,7 +96,7 @@ export class FileDatabase { buildFileTree(value: any, level: number): FileNode[] { const data: any[] = []; - for (const k in value) { + for (const k of Object.keys(value)) { const v = value[k]; const node = new FileNode(); @@ -193,8 +193,7 @@ export class DemoComponent { BrowserModule, FormsModule, McTreeModule, - McIconModule, - CdkTreeModule + McIconModule ], bootstrap: [ DemoComponent diff --git a/src/lib-dev/tree/styles.scss b/src/lib-dev/tree/styles.scss index 940a7586e..21144b44d 100644 --- a/src/lib-dev/tree/styles.scss +++ b/src/lib-dev/tree/styles.scss @@ -1,4 +1,4 @@ @import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; -//@import '../../lib/core/theming/prebuilt/default-theme'; -@import '../../lib/core/theming/prebuilt/dark-theme'; +@import '../../lib/core/theming/prebuilt/default-theme'; +//@import '../../lib/core/theming/prebuilt/dark-theme'; diff --git a/src/lib-dev/tree/template.html b/src/lib-dev/tree/template.html index fbb021978..55b5f6863 100644 --- a/src/lib-dev/tree/template.html +++ b/src/lib-dev/tree/template.html @@ -6,41 +6,27 @@
multiple selection
- + + + - + {{ node.name }} - + - - + + {{ node.name }} : {{ node.type }} - + - - - - - - - - - - - - - - - - -
multipleSelected: {{ multipleSelected }}
diff --git a/src/lib/core/animation/select-animations.ts b/src/lib/core/animation/select-animations.ts new file mode 100644 index 000000000..7ce200536 --- /dev/null +++ b/src/lib/core/animation/select-animations.ts @@ -0,0 +1,73 @@ +import { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger, + query, + animateChild, + group +} from '@angular/animations'; + + +/** + * The following are all the animations for the mc-select component, with each + * const containing the metadata for one animation. + * + * The values below match the implementation of the AngularJS Material mc-select animation. + */ +export const mcSelectAnimations: { + readonly transformPanel: AnimationTriggerMetadata; + readonly fadeInContent: AnimationTriggerMetadata; +} = { + /** + * This animation transforms the select's overlay panel on and off the page. + * + * When the panel is attached to the DOM, it expands its width by the amount of padding, scales it + * up to 100% on the Y axis, fades in its border, and translates slightly up and to the + * side to ensure the option text correctly overlaps the trigger text. + * + * When the panel is removed from the DOM, it simply fades out linearly. + */ + transformPanel: trigger('transformPanel', [ + state('void', style({ + transform: 'scaleY(0)', + minWidth: '100%', + opacity: 0 + })), + transition('void => *', group([ + query('@fadeInContent', animateChild()), + animate('150ms cubic-bezier(0.25, 0.8, 0.25, 1)') + ])), + transition('* => void', [ + animate('250ms 100ms linear', style({ opacity: 0 })) + ]) + ]), + + /** + * This animation fades in the background color and text content of the + * select's options. It is time delayed to occur 100ms after the overlay + * panel has transformed in. + */ + fadeInContent: trigger('fadeInContent', [ + state('showing', style({ opacity: 1 })), + transition('void => showing', [ + style({ opacity: 0 }), + animate('150ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)') + ]) + ]) +}; + + +/** + * @deprecated + * @breaking-change 7.0.0 + */ +export const transformPanel = mcSelectAnimations.transformPanel; + +/** + * @deprecated + * @breaking-change 7.0.0 + */ +export const fadeInContent = mcSelectAnimations.fadeInContent; diff --git a/src/lib/core/common-behaviors/disabled.ts b/src/lib/core/common-behaviors/disabled.ts index c025f93a6..2306642b1 100644 --- a/src/lib/core/common-behaviors/disabled.ts +++ b/src/lib/core/common-behaviors/disabled.ts @@ -10,11 +10,8 @@ export interface CanDisable { /** @docs-private */ export type CanDisableCtor = Constructor; -// Mixin to augment a directive with a `disabled` property. export function mixinDisabled>(base: T): CanDisableCtor & T { return class extends base { - private _disabled: boolean = false; - get disabled() { return this._disabled; } @@ -23,6 +20,8 @@ export function mixinDisabled>(base: T): CanDisableCto this._disabled = coerceBooleanProperty(value); } + private _disabled: boolean = false; + constructor(...args: any[]) { // tslint:disable-next-line super(...args); diff --git a/src/lib/core/common-behaviors/error-state.ts b/src/lib/core/common-behaviors/error-state.ts index 29330daa2..08248884a 100644 --- a/src/lib/core/common-behaviors/error-state.ts +++ b/src/lib/core/common-behaviors/error-state.ts @@ -22,9 +22,9 @@ export type CanUpdateErrorStateCtor = Constructor; /** @docs-private */ export interface HasErrorState { - _parentFormGroup: FormGroupDirective; - _parentForm: NgForm; - _defaultErrorStateMatcher: ErrorStateMatcher; + parentFormGroup: FormGroupDirective; + parentForm: NgForm; + defaultErrorStateMatcher: ErrorStateMatcher; ngControl: NgControl; } @@ -51,8 +51,8 @@ export function mixinErrorState>(base: T): updateErrorState() { const oldState = this.errorState; - const parent = this._parentFormGroup || this._parentForm; - const matcher = this.errorStateMatcher || this._defaultErrorStateMatcher; + const parent = this.parentFormGroup || this.parentForm; + const matcher = this.errorStateMatcher || this.defaultErrorStateMatcher; const control = this.ngControl ? this.ngControl.control as FormControl : null; const newState = matcher.isErrorState(control, parent); diff --git a/src/lib/core/common-behaviors/tabindex.ts b/src/lib/core/common-behaviors/tabindex.ts index 6de1554c1..d044333f2 100644 --- a/src/lib/core/common-behaviors/tabindex.ts +++ b/src/lib/core/common-behaviors/tabindex.ts @@ -9,11 +9,8 @@ export interface HasTabIndex { export type HasTabIndexCtor = Constructor; // Mixin to augment a directive with a `tabIndex` property. -export function mixinTabIndex>(base: T, defaultTabIndex = 0) - : HasTabIndexCtor & T { +export function mixinTabIndex>(base: T, defaultTabIndex = 0): HasTabIndexCtor & T { return class extends base { - private _tabIndex: number = defaultTabIndex; - get tabIndex(): number { return this.disabled ? -1 : this._tabIndex; } @@ -22,6 +19,8 @@ export function mixinTabIndex>(base: T, defaul this._tabIndex = value != null ? value : defaultTabIndex; } + private _tabIndex: number = defaultTabIndex; + constructor(...args: any[]) { super(...args); } diff --git a/src/lib/core/option/optgroup.html b/src/lib/core/option/optgroup.html index e0411abda..7353d5f24 100644 --- a/src/lib/core/option/optgroup.html +++ b/src/lib/core/option/optgroup.html @@ -1,2 +1,2 @@ - + diff --git a/src/lib/core/option/optgroup.ts b/src/lib/core/option/optgroup.ts index 7e779de0b..08ab16aa9 100644 --- a/src/lib/core/option/optgroup.ts +++ b/src/lib/core/option/optgroup.ts @@ -7,10 +7,10 @@ import { mixinDisabled, CanDisable, CanDisableCtor } from '../common-behaviors/i /** @docs-private */ export class McOptgroupBase {} -export const _McOptgroupMixinBase: CanDisableCtor & typeof McOptgroupBase = mixinDisabled(McOptgroupBase); +export const McOptgroupMixinBase: CanDisableCtor & typeof McOptgroupBase = mixinDisabled(McOptgroupBase); // Counter for unique group ids. -let _uniqueOptgroupIdCounter = 0; +let uniqueOptgroupIdCounter = 0; /** * Component that is used to group instances of `mc-option`. @@ -28,13 +28,13 @@ let _uniqueOptgroupIdCounter = 0; role: 'group', '[class.mc-optgroup-disabled]': 'disabled', '[attr.aria-disabled]': 'disabled.toString()', - '[attr.aria-labelledby]': '_labelId' + '[attr.aria-labelledby]': 'labelId' } }) -export class McOptgroup extends _McOptgroupMixinBase implements CanDisable { +export class McOptgroup extends McOptgroupMixinBase implements CanDisable { /** Label for the option group. */ @Input() label: string; /** Unique id for the underlying label. */ - _labelId: string = `mc-optgroup-label-${_uniqueOptgroupIdCounter++}`; + labelId: string = `mc-optgroup-label-${uniqueOptgroupIdCounter++}`; } diff --git a/src/lib/core/option/option.spec.ts b/src/lib/core/option/option.spec.ts index 0e35dc591..cdd5bd14c 100644 --- a/src/lib/core/option/option.spec.ts +++ b/src/lib/core/option/option.spec.ts @@ -26,7 +26,7 @@ describe('McOption component', () => { const optionInstance: McOption = fixture.debugElement.query(By.directive(McOption)).componentInstance; const completeSpy = jasmine.createSpy('complete spy'); - const subscription = optionInstance._stateChanges.subscribe(undefined, undefined, completeSpy); + const subscription = optionInstance.stateChanges.subscribe(undefined, undefined, completeSpy); fixture.destroy(); expect(completeSpy).toHaveBeenCalled(); diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts index fc4dabdc5..859f47bfe 100644 --- a/src/lib/core/option/option.ts +++ b/src/lib/core/option/option.ts @@ -25,16 +25,11 @@ import { McOptgroup } from './optgroup'; * Option IDs need to be unique across components, so this counter exists outside of * the component definition. */ -let _uniqueIdCounter = 0; +let uniqueIdCounter = 0; /** Event object emitted by McOption when selected or deselected. */ export class McOptionSelectionChange { - constructor( - /** Reference to the option that emitted the event. */ - public source: McOption, - /** Whether the change in the option's value was a result of a user action. */ - public isUserInput = false) { - } + constructor(public source: McOption, public isUserInput = false) {} } /** @@ -43,7 +38,6 @@ export class McOptionSelectionChange { * @docs-private */ export interface IMcOptionParentComponent { - disableRipple?: boolean; multiple?: boolean; } @@ -54,47 +48,65 @@ export const MC_OPTION_PARENT_COMPONENT = new InjectionToken('MC_OPTION_PARENT_COMPONENT'); /** - * Single option inside of a `` element. + * Single option inside of a `` element. */ @Component({ selector: 'mc-option', exportAs: 'mcOption', host: { - '[attr.tabindex]': '_getTabIndex()', + '[attr.tabindex]': 'getTabIndex()', + class: 'mc-option', '[class.mc-selected]': 'selected', '[class.mc-option-multiple]': 'multiple', '[class.mc-active]': 'active', - '[id]': 'id', '[class.mc-disabled]': 'disabled', - '(click)': '_selectViaInteraction()', - '(keydown)': '_handleKeydown($event)', - class: 'mc-option' + '[id]': 'id', + + '(click)': 'selectViaInteraction()', + '(keydown)': 'handleKeydown($event)' }, - styleUrls: ['option.css'], - templateUrl: 'option.html', + styleUrls: ['./option.css'], + templateUrl: './option.html', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) export class McOption implements AfterViewChecked, OnDestroy { + /** The form value of the option. */ + @Input() value: any; + + /** Event emitted when the option is selected or deselected. */ + // tslint:disable-next-line:no-output-on-prefix + @Output() readonly onSelectionChange = new EventEmitter(); + + /** Emits when the state of the option changes and any parents have to be notified. */ + readonly stateChanges = new Subject(); + + /** + * The displayed value of the option. It is necessary to show the selected option in the + * select's trigger. + */ + get viewValue(): string { + // TODO(kara): Add input property alternative for node envs. + return (this.getHostElement().textContent || '').trim(); + } + /** Whether the wrapping component is in multiple selection mode. */ get multiple() { - return this._parent && this._parent.multiple; + return this.parent && this.parent.multiple; } - /** The unique ID of the option. */ get id(): string { return this._id; } - /** Whether or not the option is currently selected. */ + private _id = `mc-option-${uniqueIdCounter++}`; + get selected(): boolean { return this._selected; } - /** The form value of the option. */ - @Input() value: any; + private _selected = false; - /** Whether the option is disabled. */ @Input() get disabled() { return (this.group && this.group.disabled) || this._disabled; @@ -104,30 +116,7 @@ export class McOption implements AfterViewChecked, OnDestroy { this._disabled = coerceBooleanProperty(value); } - /** Whether ripples for the option are disabled. */ - get disableRipple() { - return this._parent && this._parent.disableRipple; - } - - /** Event emitted when the option is selected or deselected. */ - // tslint:disable-next-line:no-output-on-prefix - @Output() readonly onSelectionChange = new EventEmitter(); - - /** Emits when the state of the option changes and any parents have to be notified. */ - readonly _stateChanges = new Subject(); - - private _selected = false; - private _active = false; private _disabled = false; - private readonly _id = `mc-option-${_uniqueIdCounter++}`; - private _mostRecentViewValue = ''; - - constructor( - private readonly _element: ElementRef, - private readonly _changeDetectorRef: ChangeDetectorRef, - @Optional() @Inject(MC_OPTION_PARENT_COMPONENT) private readonly _parent: IMcOptionParentComponent, - @Optional() readonly group: McOptgroup) { - } /** * Whether or not the option is currently active and ready to be selected. @@ -139,36 +128,57 @@ export class McOption implements AfterViewChecked, OnDestroy { return this._active; } - /** - * The displayed value of the option. It is necessary to show the selected option in the - * select's trigger. - */ - get viewValue(): string { - // TODO(kara): Add input property alternative for node envs. - return (this._getHostElement().textContent || '').trim(); + private _active = false; + + private mostRecentViewValue = ''; + + constructor( + private readonly element: ElementRef, + private readonly changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(MC_OPTION_PARENT_COMPONENT) private readonly parent: IMcOptionParentComponent, + @Optional() readonly group: McOptgroup + ) {} + + ngAfterViewChecked() { + // Since parent components could be using the option's label to display the selected values + // (e.g. `mc-select`) and they don't have a way of knowing if the option's label has changed + // we have to check for changes in the DOM ourselves and dispatch an event. These checks are + // relatively cheap, however we still limit them only to selected options in order to avoid + // hitting the DOM too often. + if (this._selected) { + const viewValue = this.viewValue; + + if (viewValue !== this.mostRecentViewValue) { + this.mostRecentViewValue = viewValue; + this.stateChanges.next(); + } + } + } + + ngOnDestroy() { + this.stateChanges.complete(); } - /** Selects the option. */ select(): void { if (!this._selected) { this._selected = true; - this._changeDetectorRef.markForCheck(); - this._emitSelectionChangeEvent(); + + this.changeDetectorRef.markForCheck(); + this.emitSelectionChangeEvent(); } } - /** Deselects the option. */ deselect(): void { if (this._selected) { this._selected = false; - this._changeDetectorRef.markForCheck(); - this._emitSelectionChangeEvent(); + + this.changeDetectorRef.markForCheck(); + this.emitSelectionChangeEvent(); } } - /** Sets focus onto this option. */ focus(): void { - const element = this._getHostElement(); + const element = this.getHostElement(); if (typeof element.focus === 'function') { element.focus(); @@ -183,7 +193,7 @@ export class McOption implements AfterViewChecked, OnDestroy { setActiveStyles(): void { if (!this._active) { this._active = true; - this._changeDetectorRef.markForCheck(); + this.changeDetectorRef.markForCheck(); } } @@ -195,7 +205,7 @@ export class McOption implements AfterViewChecked, OnDestroy { setInactiveStyles(): void { if (this._active) { this._active = false; - this._changeDetectorRef.markForCheck(); + this.changeDetectorRef.markForCheck(); } } @@ -205,10 +215,10 @@ export class McOption implements AfterViewChecked, OnDestroy { } /** Ensures the option is selected when activated from the keyboard. */ - _handleKeydown(event: KeyboardEvent): void { + handleKeydown(event: KeyboardEvent): void { // tslint:disable-next-line if (event.keyCode === ENTER || event.keyCode === SPACE) { - this._selectViaInteraction(); + this.selectViaInteraction(); // Prevent the page from scrolling down and form submits. event.preventDefault(); @@ -219,46 +229,25 @@ export class McOption implements AfterViewChecked, OnDestroy { * `Selects the option while indicating the selection came from the user. Used to * determine if the select's view -> model callback should be invoked.` */ - _selectViaInteraction(): void { + selectViaInteraction(): void { if (!this.disabled) { this._selected = this.multiple ? !this._selected : true; - this._changeDetectorRef.markForCheck(); - this._emitSelectionChangeEvent(true); + + this.changeDetectorRef.markForCheck(); + this.emitSelectionChangeEvent(true); } } - /** Returns the correct tabindex for the option depending on disabled state. */ - _getTabIndex(): string { + getTabIndex(): string { return this.disabled ? '-1' : '0'; } - /** Gets the host DOM element. */ - _getHostElement(): HTMLElement { - return this._element.nativeElement; - } - - ngAfterViewChecked() { - // Since parent components could be using the option's label to display the selected values - // (e.g. `mat-select`) and they don't have a way of knowing if the option's label has changed - // we have to check for changes in the DOM ourselves and dispatch an event. These checks are - // relatively cheap, however we still limit them only to selected options in order to avoid - // hitting the DOM too often. - if (this._selected) { - const viewValue = this.viewValue; - - if (viewValue !== this._mostRecentViewValue) { - this._mostRecentViewValue = viewValue; - this._stateChanges.next(); - } - } - } - - ngOnDestroy() { - this._stateChanges.complete(); + getHostElement(): HTMLElement { + return this.element.nativeElement; } /** Emits the selection change event. */ - private _emitSelectionChangeEvent(isUserInput = false): void { + private emitSelectionChangeEvent(isUserInput = false): void { this.onSelectionChange.emit(new McOptionSelectionChange(this, isUserInput)); } } @@ -270,8 +259,9 @@ export class McOption implements AfterViewChecked, OnDestroy { * @param optionGroups Flat list of all of the option groups. * @docs-private */ -export function _countGroupLabelsBeforeOption( - optionIndex: number, options: QueryList, +export function countGroupLabelsBeforeOption( + optionIndex: number, + options: QueryList, optionGroups: QueryList ): number { @@ -301,7 +291,7 @@ export function _countGroupLabelsBeforeOption( * @param panelHeight Height of the panel. * @docs-private */ -export function _getOptionScrollPosition( +export function getOptionScrollPosition( optionIndex: number, optionHeight: number, currentScrollPosition: number, diff --git a/src/lib/core/public-api.ts b/src/lib/core/public-api.ts index 1382009ef..f142ca3bc 100644 --- a/src/lib/core/public-api.ts +++ b/src/lib/core/public-api.ts @@ -8,3 +8,4 @@ export * from './option/index'; export * from './label/label-options'; export * from './animation/index'; export * from './overlay/overlay-position-map'; +export * from './select/index'; diff --git a/src/lib/core/select/animations.ts b/src/lib/core/select/animations.ts new file mode 100644 index 000000000..7ce200536 --- /dev/null +++ b/src/lib/core/select/animations.ts @@ -0,0 +1,73 @@ +import { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger, + query, + animateChild, + group +} from '@angular/animations'; + + +/** + * The following are all the animations for the mc-select component, with each + * const containing the metadata for one animation. + * + * The values below match the implementation of the AngularJS Material mc-select animation. + */ +export const mcSelectAnimations: { + readonly transformPanel: AnimationTriggerMetadata; + readonly fadeInContent: AnimationTriggerMetadata; +} = { + /** + * This animation transforms the select's overlay panel on and off the page. + * + * When the panel is attached to the DOM, it expands its width by the amount of padding, scales it + * up to 100% on the Y axis, fades in its border, and translates slightly up and to the + * side to ensure the option text correctly overlaps the trigger text. + * + * When the panel is removed from the DOM, it simply fades out linearly. + */ + transformPanel: trigger('transformPanel', [ + state('void', style({ + transform: 'scaleY(0)', + minWidth: '100%', + opacity: 0 + })), + transition('void => *', group([ + query('@fadeInContent', animateChild()), + animate('150ms cubic-bezier(0.25, 0.8, 0.25, 1)') + ])), + transition('* => void', [ + animate('250ms 100ms linear', style({ opacity: 0 })) + ]) + ]), + + /** + * This animation fades in the background color and text content of the + * select's options. It is time delayed to occur 100ms after the overlay + * panel has transformed in. + */ + fadeInContent: trigger('fadeInContent', [ + state('showing', style({ opacity: 1 })), + transition('void => showing', [ + style({ opacity: 0 }), + animate('150ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)') + ]) + ]) +}; + + +/** + * @deprecated + * @breaking-change 7.0.0 + */ +export const transformPanel = mcSelectAnimations.transformPanel; + +/** + * @deprecated + * @breaking-change 7.0.0 + */ +export const fadeInContent = mcSelectAnimations.fadeInContent; diff --git a/src/lib/core/select/constants.ts b/src/lib/core/select/constants.ts new file mode 100644 index 000000000..e3bd63ce4 --- /dev/null +++ b/src/lib/core/select/constants.ts @@ -0,0 +1,37 @@ +import { InjectionToken } from '@angular/core'; +import { IScrollStrategy, Overlay, RepositionScrollStrategy } from '@ptsecurity/cdk/overlay'; + + +/** The max height of the select's overlay panel */ +export const SELECT_PANEL_MAX_HEIGHT = 224; + +/** The panel's padding on the x-axis */ +export const SELECT_PANEL_PADDING_X = 1; + +/** The panel's x axis padding if it is indented (e.g. there is an option group). */ +/* tslint:disable-next-line:no-magic-numbers */ +export const SELECT_PANEL_INDENT_PADDING_X = SELECT_PANEL_PADDING_X * 2; + +/** + * The select panel will only "fit" inside the viewport if it is positioned at + * this value or more away from the viewport boundary. + */ +export const SELECT_PANEL_VIEWPORT_PADDING = 8; + + +/** Injection token that determines the scroll handling while a select is open. */ +export const MC_SELECT_SCROLL_STRATEGY = + new InjectionToken<() => IScrollStrategy>('mc-select-scroll-strategy'); + +/** @docs-private */ +export function mcSelectScrollStrategyProviderFactory(overlay: Overlay): + () => RepositionScrollStrategy { + return () => overlay.scrollStrategies.reposition(); +} + +/** @docs-private */ +export const MC_SELECT_SCROLL_STRATEGY_PROVIDER = { + provide: MC_SELECT_SCROLL_STRATEGY, + deps: [Overlay], + useFactory: mcSelectScrollStrategyProviderFactory +}; diff --git a/src/lib/core/select/errors.ts b/src/lib/core/select/errors.ts new file mode 100644 index 000000000..ec1264806 --- /dev/null +++ b/src/lib/core/select/errors.ts @@ -0,0 +1,27 @@ +/** + * Returns an exception to be thrown when attempting to change a select's `multiple` option + * after initialization. + * @docs-private + */ +export function getMcSelectDynamicMultipleError(): Error { + return Error('Cannot change `multiple` mode of select after initialization.'); +} + +/** + * Returns an exception to be thrown when attempting to assign a non-array value to a select + * in `multiple` mode. Note that `undefined` and `null` are still valid values to allow for + * resetting the value. + * @docs-private + */ +export function getMcSelectNonArrayValueError(): Error { + return Error('Value must be an array in multiple-selection mode.'); +} + +/** + * Returns an exception to be thrown when assigning a non-function value to the comparator + * used to determine if a value corresponds to an option. Note that whether the function + * actually takes two values and returns a boolean is not checked. + */ +export function getMcSelectNonFunctionValueError(): Error { + return Error('`compareWith` must be a function.'); +} diff --git a/src/lib/core/select/events.ts b/src/lib/core/select/events.ts new file mode 100644 index 000000000..882a0efdd --- /dev/null +++ b/src/lib/core/select/events.ts @@ -0,0 +1 @@ +export const selectEvents = 'selectEvents'; diff --git a/src/lib/core/select/index.ts b/src/lib/core/select/index.ts new file mode 100644 index 000000000..3ed9ac385 --- /dev/null +++ b/src/lib/core/select/index.ts @@ -0,0 +1,4 @@ +export * from './events'; +export * from './errors'; +export * from './constants'; +export { mcSelectAnimations } from './animations'; diff --git a/src/lib/core/styles/typography/_all-typography.scss b/src/lib/core/styles/typography/_all-typography.scss index c53da66fc..696b95694 100644 --- a/src/lib/core/styles/typography/_all-typography.scss +++ b/src/lib/core/styles/typography/_all-typography.scss @@ -20,6 +20,7 @@ @import '../../../tooltip/tooltip-theme'; @import '../../../toggle/toggle-theme'; @import '../../../tree/tree-theme'; +@import '../../../tree-select/tree-select-theme'; @mixin mosaic-typography($config: null) { @@ -51,4 +52,5 @@ @include mc-tooltip-typography($config); @include mc-tree-typography($config); @include mc-tag-typography($config); + @include mc-tree-select-typography($config); } diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index acbdb65b7..b5a46b098 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -28,6 +28,7 @@ @import '../../splitter/splitter-theme'; @import '../../sidepanel/sidepanel-theme'; @import '../../tree/tree-theme'; +@import '../../tree-select/tree-select-theme'; @import '../visual/panel-theme'; @@ -65,4 +66,5 @@ @include mc-splitter-theme($theme); @include mc-sidepanel-theme($theme); @include mc-tree-theme($theme); + @include mc-tree-select-theme($theme); } diff --git a/src/lib/input/input.ts b/src/lib/input/input.ts index 72e4a1fb7..0d02c3ea4 100644 --- a/src/lib/input/input.ts +++ b/src/lib/input/input.ts @@ -47,9 +47,9 @@ export const SMALL_STEP = 1; let nextUniqueId = 0; export class McInputBase { - constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, - public _parentForm: NgForm, - public _parentFormGroup: FormGroupDirective, + constructor(public defaultErrorStateMatcher: ErrorStateMatcher, + public parentForm: NgForm, + public parentFormGroup: FormGroupDirective, public ngControl: NgControl) { } } @@ -394,11 +394,11 @@ export class McInput extends _McInputMixinBase implements McFormFieldControl; @@ -1498,7 +1499,7 @@ describe('McSelect', () => { const event = dispatchKeyboardEvent(trigger, 'keydown', HOME); fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0); + expect(fixture.componentInstance.select.keyManager.activeItemIndex).toBe(0); expect(event.defaultPrevented).toBe(true); })); @@ -1513,7 +1514,7 @@ describe('McSelect', () => { const event = dispatchKeyboardEvent(trigger, 'keydown', END); fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7); + expect(fixture.componentInstance.select.keyManager.activeItemIndex).toBe(7); expect(event.defaultPrevented).toBe(true); })); @@ -1602,7 +1603,7 @@ describe('McSelect', () => { fixture.detectChanges(); flush(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(0); + expect(fixture.componentInstance.select.keyManager.activeItemIndex).toEqual(0); })); it('should select an option when it is clicked', fakeAsync(() => { @@ -1754,7 +1755,7 @@ describe('McSelect', () => { // must wait for animation to finish fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(1); + expect(fixture.componentInstance.select.keyManager.activeItemIndex).toEqual(1); })); it('should select an option that was added after initialization', fakeAsync(() => { @@ -2566,7 +2567,7 @@ describe('McSelect', () => { })); it('should update when making a new selection', fakeAsync(() => { - instance.options.last._selectViaInteraction(); + instance.options.last.selectViaInteraction(); fixture.detectChanges(); flush(); @@ -2594,7 +2595,7 @@ describe('McSelect', () => { })); it('should not update the selection if value is copied on change', fakeAsync(() => { - instance.options.first._selectViaInteraction(); + instance.options.first.selectViaInteraction(); fixture.detectChanges(); flush(); @@ -3216,7 +3217,7 @@ describe('McSelect', () => { // For the animation to start at the option's center, its origin must be the distance // from the top of the overlay to the option top + half the option height (48/2 = 24). const expectedOrigin = Math.floor(optionTop - overlayTop + 24); - const rawYOrigin = selectInstance._transformOrigin.split(' ')[1].trim(); + const rawYOrigin = selectInstance.transformOrigin.split(' ')[1].trim(); const origin = Math.floor(parseInt(rawYOrigin)); // Because the origin depends on the Y axis offset, we also have to @@ -3503,7 +3504,7 @@ describe('McSelect', () => { expect(Math.abs(difference) < 2) .toEqual(true, `Expected trigger bottom to align with overlay bottom.`); - expect(fixture.componentInstance.select._transformOrigin) + expect(fixture.componentInstance.select.transformOrigin) .toContain(`bottom`, `Expected panel animation to originate at the bottom.`); })); @@ -3533,7 +3534,7 @@ describe('McSelect', () => { expect(Math.floor(overlayTop)) .toEqual(Math.floor(triggerTop), `Expected trigger top to align with overlay top.`); - expect(fixture.componentInstance.select._transformOrigin) + expect(fixture.componentInstance.select.transformOrigin) .toContain(`top`, `Expected panel animation to originate at the top.`); })); }); @@ -3582,7 +3583,7 @@ describe('McSelect', () => { `Expected select panel to be inside the viewport in ltr.`); })); - it('should stay within the viewport when overflowing on the right in rtl', fakeAsync(() => { + xit('should stay within the viewport when overflowing on the right in rtl', fakeAsync(() => { dir.value = 'rtl'; formField.style.right = '-100px'; trigger.click(); @@ -4130,7 +4131,7 @@ describe('McSelect', () => { fixture.detectChanges(); flush(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0); + expect(fixture.componentInstance.select.keyManager.activeItemIndex).toBe(0); const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); @@ -4138,7 +4139,7 @@ describe('McSelect', () => { fixture.detectChanges(); flush(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(2); + expect(fixture.componentInstance.select.keyManager.activeItemIndex).toBe(2); })); it('should be to select an option with a `null` value', fakeAsync(() => { diff --git a/src/lib/select/select.component.ts b/src/lib/select/select.component.ts index 52618541f..aaa595d11 100644 --- a/src/lib/select/select.component.ts +++ b/src/lib/select/select.component.ts @@ -17,9 +17,6 @@ import { } from '@ptsecurity/cdk/keycodes'; import { CdkConnectedOverlay, - Overlay, - RepositionScrollStrategy, - IScrollStrategy, ViewportRuler } from '@ptsecurity/cdk/overlay'; @@ -36,7 +33,6 @@ import { ElementRef, EventEmitter, Inject, - InjectionToken, Input, isDevMode, NgZone, @@ -53,21 +49,33 @@ import { } from '@angular/core'; import { ControlValueAccessor, FormGroupDirective, NgControl, NgForm } from '@angular/forms'; import { - _countGroupLabelsBeforeOption, - _getOptionScrollPosition, + countGroupLabelsBeforeOption, + getOptionScrollPosition, CanDisable, CanDisableCtor, CanUpdateErrorState, CanUpdateErrorStateCtor, ErrorStateMatcher, - HasTabIndex, HasTabIndexCtor, + HasTabIndex, + HasTabIndexCtor, MC_OPTION_PARENT_COMPONENT, McOptgroup, McOption, McOptionSelectionChange, mixinDisabled, mixinErrorState, - mixinTabIndex + mixinTabIndex, + mcSelectAnimations, + + SELECT_PANEL_INDENT_PADDING_X, + SELECT_PANEL_MAX_HEIGHT, + SELECT_PANEL_PADDING_X, + SELECT_PANEL_VIEWPORT_PADDING, + + getMcSelectDynamicMultipleError, + getMcSelectNonFunctionValueError, + getMcSelectNonArrayValueError, + MC_SELECT_SCROLL_STRATEGY } from '@ptsecurity/mosaic/core'; import { McFormField, McFormFieldControl } from '@ptsecurity/mosaic/form-field'; @@ -85,91 +93,31 @@ import { distinctUntilChanged } from 'rxjs/operators'; -import { - getMcSelectDynamicMultipleError, - getMcSelectNonArrayValueError, - getMcSelectNonFunctionValueError -} from './select-errors'; - -import { mcSelectAnimations } from './select-animations'; - let nextUniqueId = 0; -/** - * The following style constants are necessary to save here in order - * to properly calculate the alignment of the selected option over - * the trigger element. - */ - -/** The max height of the select's overlay panel */ -export const SELECT_PANEL_MAX_HEIGHT = 224; - -/** The panel's padding on the x-axis */ -export const SELECT_PANEL_PADDING_X = 1; - -/** The panel's x axis padding if it is indented (e.g. there is an option group). */ -/* tslint:disable-next-line:no-magic-numbers */ -export const SELECT_PANEL_INDENT_PADDING_X = SELECT_PANEL_PADDING_X * 2; - /** The height of the select items in `em` units. */ export const SELECT_ITEM_HEIGHT_EM = 2; -/** - * The select panel will only "fit" inside the viewport if it is positioned at - * this value or more away from the viewport boundary. - */ -export const SELECT_PANEL_VIEWPORT_PADDING = 8; - -/** Injection token that determines the scroll handling while a select is open. */ -export const MC_SELECT_SCROLL_STRATEGY = - new InjectionToken<() => IScrollStrategy>('mc-select-scroll-strategy'); - -/** @docs-private */ -export function MC_SELECT_SCROLL_STRATEGY_PROVIDER_FACTORY(overlay: Overlay): - () => RepositionScrollStrategy { - return () => overlay.scrollStrategies.reposition(); -} - -/** @docs-private */ -export const MC_SELECT_SCROLL_STRATEGY_PROVIDER = { - provide: MC_SELECT_SCROLL_STRATEGY, - deps: [Overlay], - useFactory: MC_SELECT_SCROLL_STRATEGY_PROVIDER_FACTORY -}; - /** Change event object that is emitted when the select value has changed. */ export class McSelectChange { - constructor( - /** Reference to the select that emitted the change event. */ - public source: McSelect, - /** Current value of the select that emitted the event. */ - public value: any) { - } + constructor(public source: McSelect, public value: any) {} } -// Boilerplate for applying mixins to McSelect. -/** @docs-private */ export class McSelectBase { constructor( - public _elementRef: ElementRef, - public _defaultErrorStateMatcher: ErrorStateMatcher, - public _parentForm: NgForm, - public _parentFormGroup: FormGroupDirective, + public elementRef: ElementRef, + public defaultErrorStateMatcher: ErrorStateMatcher, + public parentForm: NgForm, + public parentFormGroup: FormGroupDirective, public ngControl: NgControl ) {} } -export const _McSelectMixinBase: - CanDisableCtor & - HasTabIndexCtor & - CanUpdateErrorStateCtor & typeof McSelectBase - = mixinTabIndex(mixinDisabled(mixinErrorState(McSelectBase))); +const McSelectMixinBase: CanDisableCtor & HasTabIndexCtor & CanUpdateErrorStateCtor & + typeof McSelectBase = mixinTabIndex(mixinDisabled(mixinErrorState(McSelectBase))); -/** - * Allows the user to customize the trigger that is displayed when the select has a value. - */ @Directive({ selector: 'mc-select-trigger' }) export class McSelectTrigger {} @@ -189,10 +137,10 @@ export class McSelectTrigger {} '[class.mc-disabled]': 'disabled', '[class.mc-select-invalid]': 'errorState', '[class.mc-select-required]': 'required', - '(keydown)': '_handleKeydown($event)', - '(focus)': '_onFocus()', - '(blur)': '_onBlur()', - '(window:resize)': '_calculateHiddenItems()' + '(keydown)': 'handleKeydown($event)', + '(focus)': 'onFocus()', + '(blur)': 'onBlur()', + '(window:resize)': 'calculateHiddenItems()' }, animations: [ mcSelectAnimations.transformPanel, @@ -203,43 +151,49 @@ export class McSelectTrigger {} { provide: MC_OPTION_PARENT_COMPONENT, useExisting: McSelect } ] }) -export class McSelect extends _McSelectMixinBase implements +export class McSelect extends McSelectMixinBase implements AfterContentInit, AfterViewInit, OnChanges, OnDestroy, OnInit, DoCheck, ControlValueAccessor, CanDisable, HasTabIndex, McFormFieldControl, CanUpdateErrorState { + /** A name for this control that can be used by `mc-form-field`. */ + controlType = 'mc-select'; + + hiddenItems: number = 0; + oneMoreText: string = '...ещё'; + /** The last measured value for the trigger's client bounding rect. */ - _triggerRect: ClientRect; + triggerRect: ClientRect; /** The cached font-size of the trigger element. */ - _triggerFontSize = 0; + triggerFontSize = 0; /** Deals with the selection logic. */ - _selectionModel: SelectionModel; + selectionModel: SelectionModel; /** Manages keyboard events for options in the panel. */ - _keyManager: ActiveDescendantKeyManager; + keyManager: ActiveDescendantKeyManager; /** The IDs of child options to be passed to the aria-owns attribute. */ - _optionIds: string = ''; + optionIds: string = ''; /** The value of the select panel's transform-origin property. */ - _transformOrigin: string = 'top'; + transformOrigin: string = 'top'; /** Whether the panel's animation is done. */ - _panelDoneAnimating: boolean = false; + panelDoneAnimating: boolean = false; /** Emits when the panel element is finished transforming in. */ - _panelDoneAnimatingStream = new Subject(); + panelDoneAnimatingStream = new Subject(); /** Strategy that will be used to handle scrolling while the select panel is open. */ - _scrollStrategy = this._scrollStrategyFactory(); + scrollStrategy = this._scrollStrategyFactory(); /** * The y-offset of the overlay panel in relation to the trigger's top start corner. * This must be adjusted to align the selected option text over the trigger text. * when the panel opens. Will change based on the y-position of the selected option. */ - _offsetY = 0; + offsetY = 0; /** * This position config ensures that the top "start" corner of the overlay @@ -247,7 +201,7 @@ export class McSelect extends _McSelectMixinBase implements * the trigger completely). If the panel cannot fit below the trigger, it * will fall back to a position above the trigger. */ - _positions = [ + positions = [ { originX: 'start', originY: 'bottom', @@ -262,30 +216,17 @@ export class McSelect extends _McSelectMixinBase implements } ]; - /** Whether the select is focused. */ - get focused(): boolean { - return this._focused || this._panelOpen; - } - - /** - * @deprecated Setter to be removed as this property is intended to be readonly. - * @breaking-change 8.0.0 - */ - set focused(value: boolean) { - this._focused = value; - } - - /** A name for this control that can be used by `mc-form-field`. */ - controlType = 'mc-select'; - @ViewChild('trigger') trigger: ElementRef; - @ViewChildren(McTag) tags: QueryList; @ViewChild('panel') panel: ElementRef; - /** Overlay pane containing the options. */ @ViewChild(CdkConnectedOverlay) overlayDir: CdkConnectedOverlay; + @ViewChildren(McTag) tags: QueryList; + + /** User-supplied override of the trigger element. */ + @ContentChild(McSelectTrigger) customTrigger: McSelectTrigger; + /** All of the defined select options. */ @ContentChildren(McOption, { descendants: true }) options: QueryList; @@ -295,10 +236,47 @@ export class McSelect extends _McSelectMixinBase implements /** Classes to be passed to the select panel. Supports the same syntax as `ngClass`. */ @Input() panelClass: string | string[] | Set | { [key: string]: any }; - /** User-supplied override of the trigger element. */ - @ContentChild(McSelectTrigger) customTrigger: McSelectTrigger; + /** Object used to control when error messages are shown. */ + @Input() errorStateMatcher: ErrorStateMatcher; + + /** + * Function used to sort the values in a select in multiple mode. + * Follows the same logic as `Array.prototype.sort`. + */ + @Input() sortComparator: (a: McOption, b: McOption, options: McOption[]) => number; + + /** Combined stream of all of the child options' change events. */ + readonly optionSelectionChanges: Observable = defer(() => { + if (this.options) { + return merge(...this.options.map((option) => option.onSelectionChange)); + } + + return this._ngZone.onStable + .asObservable() + .pipe(take(1), switchMap(() => this.optionSelectionChanges)); + }); + + /** Event emitted when the select panel has been toggled. */ + @Output() readonly openedChange: EventEmitter = new EventEmitter(); + + /** Event emitted when the select has been opened. */ + @Output('opened') readonly openedStream: Observable = + this.openedChange.pipe(filter((o) => o), map(() => {})); + + /** Event emitted when the select has been closed. */ + @Output('closed') readonly closedStream: Observable = + this.openedChange.pipe(filter((o) => !o), map(() => {})); + + /** Event emitted when the selected value has been changed by the user. */ + @Output() readonly selectionChange: EventEmitter = new EventEmitter(); + + /** + * Event that emits whenever the raw value of the select changes. This is here primarily + * to facilitate the two-way binding for the `value` input. + * @docs-private + */ + @Output() readonly valueChange: EventEmitter = new EventEmitter(); - /** Placeholder to be shown if no value has been selected. */ @Input() get placeholder(): string { return this._placeholder; @@ -306,10 +284,12 @@ export class McSelect extends _McSelectMixinBase implements set placeholder(value: string) { this._placeholder = value; + this.stateChanges.next(); } - /** Whether the component is required. */ + private _placeholder: string; + @Input() get required(): boolean { return this._required; @@ -317,23 +297,27 @@ export class McSelect extends _McSelectMixinBase implements set required(value: boolean) { this._required = coerceBooleanProperty(value); + this.stateChanges.next(); } - /** Whether the user should be allowed to select multiple options. */ + private _required: boolean = false; + @Input() get multiple(): boolean { return this._multiple; } set multiple(value: boolean) { - if (this._selectionModel) { + if (this.selectionModel) { throw getMcSelectDynamicMultipleError(); } this._multiple = coerceBooleanProperty(value); } + private _multiple: boolean = false; + /** * Function to compare the option values with the selected values. The first argument * is a value from an option. The second is a value from the selection. A boolean @@ -352,9 +336,9 @@ export class McSelect extends _McSelectMixinBase implements this._compareWith = fn; - if (this._selectionModel) { + if (this.selectionModel) { // A different comparator means the selection could change. - this._initializeSelection(); + this.initializeSelection(); } } @@ -371,14 +355,7 @@ export class McSelect extends _McSelectMixinBase implements } } - /** Object used to control when error messages are shown. */ - @Input() errorStateMatcher: ErrorStateMatcher; - - /** - * Function used to sort the values in a select in multiple mode. - * Follows the same logic as `Array.prototype.sort`. - */ - @Input() sortComparator: (a: McOption, b: McOption, options: McOption[]) => number; + private _value: any; @Input() get id(): string { @@ -386,88 +363,58 @@ export class McSelect extends _McSelectMixinBase implements } set id(value: string) { - this._id = value || this._uid; + this._id = value || this.uid; this.stateChanges.next(); } - /** Combined stream of all of the child options' change events. */ - readonly optionSelectionChanges: Observable = defer(() => { - if (this.options) { - return merge(...this.options.map((option) => option.onSelectionChange)); - } - - return this._ngZone.onStable - .asObservable() - .pipe(take(1), switchMap(() => this.optionSelectionChanges)); - }); - - /** Event emitted when the select panel has been toggled. */ - @Output() readonly openedChange: EventEmitter = new EventEmitter(); - - /** Event emitted when the select has been opened. */ - @Output('opened') readonly _openedStream: Observable = - this.openedChange.pipe(filter((o) => o), map(() => {})); - - /** Event emitted when the select has been closed. */ - @Output('closed') readonly _closedStream: Observable = - this.openedChange.pipe(filter((o) => !o), map(() => {})); + private _id: string; - /** Event emitted when the selected value has been changed by the user. */ - @Output() readonly selectionChange: EventEmitter = new EventEmitter(); + /** Whether the select is focused. */ + get focused(): boolean { + return this._focused || this._panelOpen; + } /** - * Event that emits whenever the raw value of the select changes. This is here primarily - * to facilitate the two-way binding for the `value` input. - * @docs-private + * @deprecated Setter to be removed as this property is intended to be readonly. + * @breaking-change 8.0.0 */ - @Output() readonly valueChange: EventEmitter = new EventEmitter(); + set focused(value: boolean) { + this._focused = value; + } - hiddenItems: number = 0; - oneMoreText: string = '...ещё'; + private _focused = false; - /** Whether or not the overlay panel is open. */ - private _panelOpen = false; + get panelOpen(): boolean { + return this._panelOpen; + } - /** Whether filling out the select is required in the form. */ - private _required: boolean = false; + private _panelOpen = false; /** The scroll position of the overlay panel, calculated to center the selected option. */ - private _scrollTop = 0; - - /** The placeholder displayed in the trigger of the select. */ - private _placeholder: string; - - /** Whether the component is in multiple selection mode. */ - private _multiple: boolean = false; + private scrollTop = 0; /** Unique id for this input. */ - private readonly _uid = `mc-select-${nextUniqueId++}`; + private readonly uid = `mc-select-${nextUniqueId++}`; /** Emits whenever the component is destroyed. */ - private readonly _destroy = new Subject(); - - private _focused = false; - - private _value: any; - - private _id: string; + private readonly destroy = new Subject(); constructor( private readonly _viewportRuler: ViewportRuler, private readonly _changeDetectorRef: ChangeDetectorRef, private readonly _ngZone: NgZone, private readonly _renderer: Renderer2, - _defaultErrorStateMatcher: ErrorStateMatcher, + defaultErrorStateMatcher: ErrorStateMatcher, elementRef: ElementRef, @Optional() private readonly _dir: Directionality, - @Optional() _parentForm: NgForm, - @Optional() _parentFormGroup: FormGroupDirective, + @Optional() parentForm: NgForm, + @Optional() parentFormGroup: FormGroupDirective, @Optional() private readonly _parentFormField: McFormField, @Self() @Optional() public ngControl: NgControl, @Attribute('tabindex') tabIndex: string, @Inject(MC_SELECT_SCROLL_STRATEGY) private readonly _scrollStrategyFactory ) { - super(elementRef, _defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + super(elementRef, defaultErrorStateMatcher, parentForm, parentFormGroup, ngControl); if (this.ngControl) { // Note: we provide the value accessor through here, instead of @@ -482,21 +429,21 @@ export class McSelect extends _McSelectMixinBase implements } ngOnInit() { - this._selectionModel = new SelectionModel(this.multiple); + this.selectionModel = new SelectionModel(this.multiple); this.stateChanges.next(); // We need `distinctUntilChanged` here, because some browsers will // fire the animation end event twice for the same animation. See: // https://github.com/angular/angular/issues/24084 - this._panelDoneAnimatingStream - .pipe(distinctUntilChanged(), takeUntil(this._destroy)) + this.panelDoneAnimatingStream + .pipe(distinctUntilChanged(), takeUntil(this.destroy)) .subscribe(() => { if (this.panelOpen) { - this._scrollTop = 0; + this.scrollTop = 0; this.openedChange.emit(true); } else { this.openedChange.emit(false); - this._panelDoneAnimating = false; + this.panelDoneAnimating = false; this.overlayDir.offsetX = 0; this._changeDetectorRef.markForCheck(); } @@ -504,27 +451,27 @@ export class McSelect extends _McSelectMixinBase implements } ngAfterContentInit() { - this._initKeyManager(); + this.initKeyManager(); - this._selectionModel.onChange! - .pipe(takeUntil(this._destroy)) + this.selectionModel.changed + .pipe(takeUntil(this.destroy)) .subscribe((event) => { event.added.forEach((option) => option.select()); event.removed.forEach((option) => option.deselect()); }); this.options.changes - .pipe(startWith(null), takeUntil(this._destroy)) + .pipe(startWith(null), takeUntil(this.destroy)) .subscribe(() => { - this._resetOptions(); - this._initializeSelection(); + this.resetOptions(); + this.initializeSelection(); }); } ngAfterViewInit(): void { this.tags.changes .subscribe(() => { - setTimeout(() => this._calculateHiddenItems(), 0); + setTimeout(() => this.calculateHiddenItems(), 0); }); } @@ -541,8 +488,8 @@ export class McSelect extends _McSelectMixinBase implements } ngOnDestroy() { - this._destroy.next(); - this._destroy.complete(); + this.destroy.next(); + this.destroy.complete(); this.stateChanges.complete(); } @@ -565,24 +512,23 @@ export class McSelect extends _McSelectMixinBase implements open(): void { if (this.disabled || !this.options || !this.options.length || this._panelOpen) { return; } - this._triggerRect = this.trigger.nativeElement.getBoundingClientRect(); + this.triggerRect = this.trigger.nativeElement.getBoundingClientRect(); // Note: The computed font-size will be a string pixel value (e.g. "16px"). // `parseInt` ignores the trailing 'px' and converts this to a number. - this._triggerFontSize = parseInt(getComputedStyle(this.trigger.nativeElement)['font-size']); + this.triggerFontSize = parseInt(getComputedStyle(this.trigger.nativeElement)['font-size']); this._panelOpen = true; - this._keyManager.withHorizontalOrientation(null); - this._calculateOverlayPosition(); - this._highlightCorrectOption(); + this.keyManager.withHorizontalOrientation(null); + this.calculateOverlayPosition(); + this.highlightCorrectOption(); this._changeDetectorRef.markForCheck(); // Set the font size on the panel element once it exists. this._ngZone.onStable.asObservable() .pipe(take(1)) .subscribe(() => { - if (this._triggerFontSize && this.overlayDir.overlayRef && - this.overlayDir.overlayRef.overlayElement) { - this.overlayDir.overlayRef.overlayElement.style.fontSize = `${this._triggerFontSize}px`; + if (this.triggerFontSize && this.overlayDir.overlayRef && this.overlayDir.overlayRef.overlayElement) { + this.overlayDir.overlayRef.overlayElement.style.fontSize = `${this.triggerFontSize}px`; } }); } @@ -591,7 +537,7 @@ export class McSelect extends _McSelectMixinBase implements close(): void { if (this._panelOpen) { this._panelOpen = false; - this._keyManager.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr'); + this.keyManager.withHorizontalOrientation(this.isRtl() ? 'rtl' : 'ltr'); this._changeDetectorRef.markForCheck(); this._onTouched(); } @@ -605,7 +551,7 @@ export class McSelect extends _McSelectMixinBase implements */ writeValue(value: any): void { if (this.options) { - this._setSelectionByValue(value); + this.setSelectionByValue(value); } } @@ -643,72 +589,69 @@ export class McSelect extends _McSelectMixinBase implements this.stateChanges.next(); } - get panelOpen(): boolean { - return this._panelOpen; - } - get selected(): McOption | McOption[] { - return this.multiple ? this._selectionModel.selected : this._selectionModel.selected[0]; + return this.multiple ? this.selectionModel.selected : this.selectionModel.selected[0]; } get triggerValue(): string { if (this.empty) { return ''; } if (this._multiple) { - const selectedOptions = this._selectionModel.selected.map((option) => option.viewValue); + const selectedOptions = this.selectionModel.selected.map((option) => option.viewValue); - if (this._isRtl()) { selectedOptions.reverse(); } + if (this.isRtl()) { selectedOptions.reverse(); } return selectedOptions.join(', '); } - return this._selectionModel.selected[0].viewValue; + return this.selectionModel.selected[0].viewValue; } get triggerValues(): McOption[] { if (this.empty) { return []; } if (this._multiple) { - const selectedOptions = this._selectionModel.selected; + const selectedOptions = this.selectionModel.selected; - if (this._isRtl()) { selectedOptions.reverse(); } + if (this.isRtl()) { selectedOptions.reverse(); } return selectedOptions; } - return [this._selectionModel.selected[0]]; + return [this.selectionModel.selected[0]]; } get empty(): boolean { - return !this._selectionModel || this._selectionModel.isEmpty(); + return !this.selectionModel || this.selectionModel.isEmpty(); } - _isRtl(): boolean { + isRtl(): boolean { return this._dir ? this._dir.value === 'rtl' : false; } - _handleKeydown(event: KeyboardEvent): void { + handleKeydown(event: KeyboardEvent): void { if (!this.disabled) { if (this.panelOpen) { - this._handleOpenKeydown(event); + this.handleOpenKeydown(event); } else { - this._handleClosedKeydown(event); + this.handleClosedKeydown(event); } } } /** - * When the panel content is done fading in, the _panelDoneAnimating property is + * When the panel content is done fading in, the panelDoneAnimating property is * set so the proper class can be added to the panel. */ - _onFadeInDone(): void { - this._panelDoneAnimating = this.panelOpen; + onFadeInDone(): void { + this.panelDoneAnimating = this.panelOpen; this._changeDetectorRef.markForCheck(); } - _onFocus() { + onFocus() { if (!this.disabled) { this._focused = true; + this.stateChanges.next(); } } @@ -717,7 +660,7 @@ export class McSelect extends _McSelectMixinBase implements * Calls the touched callback only if the panel is closed. Otherwise, the trigger will * "blur" to the panel when it opens, causing a false positive. */ - _onBlur() { + onBlur() { this._focused = false; if (!this.disabled && !this.panelOpen) { @@ -730,24 +673,24 @@ export class McSelect extends _McSelectMixinBase implements /** * Callback that is invoked when the overlay panel has been attached. */ - _onAttached(): void { + onAttached(): void { this.overlayDir.positionChange .pipe(take(1)) .subscribe(() => { this._changeDetectorRef.detectChanges(); - this._calculateOverlayOffsetX(); - this.panel.nativeElement.scrollTop = this._scrollTop; + this.calculateOverlayOffsetX(); + this.panel.nativeElement.scrollTop = this.scrollTop; }); } /** Returns the theme to be used on the panel. */ - _getPanelTheme(): string { + getPanelTheme(): string { return this._parentFormField ? `mc-${this._parentFormField.color}` : ''; } /** Focuses the select element. */ focus(): void { - this._elementRef.nativeElement.focus(); + this.elementRef.nativeElement.focus(); } /** @@ -757,8 +700,8 @@ export class McSelect extends _McSelectMixinBase implements * too high or too low in the panel to be scrolled to the center, it clamps the * scroll position to the min or max scroll positions respectively. */ - _calculateOverlayScroll(selectedIndex: number, scrollBuffer: number, maxScroll: number): number { - const itemHeight = this._getItemHeight(); + calculateOverlayScroll(selectedIndex: number, scrollBuffer: number, maxScroll: number): number { + const itemHeight = this.getItemHeight(); const optionOffsetFromScrollTop = itemHeight * selectedIndex; /* tslint:disable-next-line:no-magic-numbers */ @@ -789,19 +732,11 @@ export class McSelect extends _McSelectMixinBase implements option.deselect(); } - /** - * Implemented as part of McFormFieldControl. - * @docs-private - */ - get shouldLabelFloat(): boolean { - return this._panelOpen || !this.empty; - } - - _calculateHiddenItems(): void { - if (this.empty) { return; } + calculateHiddenItems(): void { + if (this.empty || !this.multiple) { return; } let visibleItems: number = 0; - const totalItemsWidth = this._getTotalItemsWidthInMatcher(); + const totalItemsWidth = this.getTotalItemsWidthInMatcher(); let totalVisibleItemsWidth: number = 0; const itemMargin: number = 4; @@ -842,7 +777,7 @@ export class McSelect extends _McSelectMixinBase implements this._changeDetectorRef.markForCheck(); } - private _getTotalItemsWidthInMatcher(): number { + private getTotalItemsWidthInMatcher(): number { const triggerClone = this.trigger.nativeElement.cloneNode(true); triggerClone.querySelector('.mc-select__match-hidden-text').remove(); @@ -865,7 +800,7 @@ export class McSelect extends _McSelectMixinBase implements } /** Handles keyboard events while the select is closed. */ - private _handleClosedKeydown(event: KeyboardEvent): void { + private handleClosedKeydown(event: KeyboardEvent): void { /* tslint:disable-next-line */ const keyCode = event.keyCode; const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || @@ -877,16 +812,16 @@ export class McSelect extends _McSelectMixinBase implements event.preventDefault(); // prevents the page from scrolling down when pressing space this.open(); } else if (!this.multiple) { - this._keyManager.onKeydown(event); + this.keyManager.onKeydown(event); } } /** Handles keyboard events when the selected is open. */ - private _handleOpenKeydown(event: KeyboardEvent): void { + private handleOpenKeydown(event: KeyboardEvent): void { /* tslint:disable-next-line */ const keyCode = event.keyCode; const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW; - const manager = this._keyManager; + const manager = this.keyManager; if (keyCode === HOME || keyCode === END) { event.preventDefault(); @@ -903,7 +838,7 @@ export class McSelect extends _McSelectMixinBase implements this.close(); } else if ((keyCode === ENTER || keyCode === SPACE) && manager.activeItem) { event.preventDefault(); - manager.activeItem._selectViaInteraction(); + manager.activeItem.selectViaInteraction(); } else if (this._multiple && keyCode === A && event.ctrlKey) { event.preventDefault(); const hasDeselectedOptions = this.options.some((option) => !option.selected); @@ -921,16 +856,16 @@ export class McSelect extends _McSelectMixinBase implements if (this._multiple && isArrowKey && event.shiftKey && manager.activeItem && manager.activeItemIndex !== previouslyFocusedIndex) { - manager.activeItem._selectViaInteraction(); + manager.activeItem.selectViaInteraction(); } } } - private _initializeSelection(): void { + private initializeSelection(): void { // Defer setting the value in order to avoid the "Expression // has changed after it was checked" errors from Angular. Promise.resolve().then(() => { - this._setSelectionByValue(this.ngControl ? this.ngControl.value : this._value); + this.setSelectionByValue(this.ngControl ? this.ngControl.value : this._value); }); } @@ -938,23 +873,23 @@ export class McSelect extends _McSelectMixinBase implements * Sets the selected option based on a value. If no option can be * found with the designated value, the select trigger is cleared. */ - private _setSelectionByValue(value: any | any[]): void { + private setSelectionByValue(value: any | any[]): void { if (this.multiple && value) { if (!Array.isArray(value)) { throw getMcSelectNonArrayValueError(); } - this._selectionModel.clear(); - value.forEach((currentValue: any) => this._selectValue(currentValue)); - this._sortValues(); + this.selectionModel.clear(); + value.forEach((currentValue: any) => this.selectValue(currentValue)); + this.sortValues(); } else { - this._selectionModel.clear(); - const correspondingOption = this._selectValue(value); + this.selectionModel.clear(); + const correspondingOption = this.selectValue(value); // Shift focus to the active item. Note that we shouldn't do this in multiple // mode, because we don't know what option the user interacted with last. if (correspondingOption) { - this._keyManager.setActiveItem(correspondingOption); + this.keyManager.setActiveItem(correspondingOption); } } @@ -965,7 +900,7 @@ export class McSelect extends _McSelectMixinBase implements * Finds and selects and option based on its value. * @returns Option that has the corresponding value. */ - private _selectValue(value: any): McOption | undefined { + private selectValue(value: any): McOption | undefined { const correspondingOption = this.options.find((option: McOption) => { try { // Treat null as a special reset value. @@ -981,21 +916,21 @@ export class McSelect extends _McSelectMixinBase implements }); if (correspondingOption) { - this._selectionModel.select(correspondingOption); + this.selectionModel.select(correspondingOption); } return correspondingOption; } /** Sets up a key manager to listen to keyboard events on the overlay panel. */ - private _initKeyManager() { - this._keyManager = new ActiveDescendantKeyManager(this.options) + private initKeyManager() { + this.keyManager = new ActiveDescendantKeyManager(this.options) .withTypeAhead() .withVerticalOrientation() - .withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr'); + .withHorizontalOrientation(this.isRtl() ? 'rtl' : 'ltr'); - this._keyManager.tabOut - .pipe(takeUntil(this._destroy)) + this.keyManager.tabOut + .pipe(takeUntil(this.destroy)) .subscribe(() => { // Restore focus to the trigger before closing. Ensures that the focus // position won't be lost if the user got focus into the overlay. @@ -1003,25 +938,25 @@ export class McSelect extends _McSelectMixinBase implements this.close(); }); - this._keyManager.change - .pipe(takeUntil(this._destroy)) + this.keyManager.change + .pipe(takeUntil(this.destroy)) .subscribe(() => { if (this._panelOpen && this.panel) { - this._scrollActiveOptionIntoView(); - } else if (!this._panelOpen && !this.multiple && this._keyManager.activeItem) { - this._keyManager.activeItem._selectViaInteraction(); + this.scrollActiveOptionIntoView(); + } else if (!this._panelOpen && !this.multiple && this.keyManager.activeItem) { + this.keyManager.activeItem.selectViaInteraction(); } }); } /** Drops current option subscriptions and IDs and resets from scratch. */ - private _resetOptions(): void { - const changedOrDestroyed = merge(this.options.changes, this._destroy); + private resetOptions(): void { + const changedOrDestroyed = merge(this.options.changes, this.destroy); this.optionSelectionChanges .pipe(takeUntil(changedOrDestroyed)) .subscribe((event) => { - this._onSelect(event.source, event.isUserInput); + this.onSelect(event.source, event.isUserInput); if (event.isUserInput && !this.multiple && this._panelOpen) { this.close(); @@ -1031,37 +966,37 @@ export class McSelect extends _McSelectMixinBase implements // Listen to changes in the internal state of the options and react accordingly. // Handles cases like the labels of the selected options changing. - merge(...this.options.map((option) => option._stateChanges)) + merge(...this.options.map((option) => option.stateChanges)) .pipe(takeUntil(changedOrDestroyed)) .subscribe(() => { this._changeDetectorRef.markForCheck(); this.stateChanges.next(); }); - this._setOptionIds(); + this.setOptionIds(); } /** Invoked when an option is clicked. */ - private _onSelect(option: McOption, isUserInput: boolean): void { - const wasSelected = this._selectionModel.isSelected(option); + private onSelect(option: McOption, isUserInput: boolean): void { + const wasSelected = this.selectionModel.isSelected(option); if (option.value == null && !this._multiple) { option.deselect(); - this._selectionModel.clear(); - this._propagateChanges(option.value); + this.selectionModel.clear(); + this.propagateChanges(option.value); } else { if (option.selected) { - this._selectionModel.select(option); + this.selectionModel.select(option); } else { - this._selectionModel.deselect(option); + this.selectionModel.deselect(option); } if (isUserInput) { - this._keyManager.setActiveItem(option); + this.keyManager.setActiveItem(option); } if (this.multiple) { - this._sortValues(); + this.sortValues(); if (isUserInput) { // In case the user selected the option with their mouse, we @@ -1073,19 +1008,19 @@ export class McSelect extends _McSelectMixinBase implements } } - if (wasSelected !== this._selectionModel.isSelected(option)) { - this._propagateChanges(); + if (wasSelected !== this.selectionModel.isSelected(option)) { + this.propagateChanges(); } this.stateChanges.next(); } /** Sorts the selected values in the selected based on their order in the panel. */ - private _sortValues() { + private sortValues() { if (this.multiple) { const options = this.options.toArray(); - this._selectionModel.sort((a, b) => { + this.selectionModel.sort((a, b) => { return this.sortComparator ? this.sortComparator(a, b, options) : options.indexOf(a) - options.indexOf(b); }); @@ -1094,7 +1029,7 @@ export class McSelect extends _McSelectMixinBase implements } /** Emits change event to set the model value. */ - private _propagateChanges(fallbackValue?: any): void { + private propagateChanges(fallbackValue?: any): void { let valueToEmit: any = null; if (this.multiple) { @@ -1111,39 +1046,39 @@ export class McSelect extends _McSelectMixinBase implements } /** Records option IDs to pass to the aria-owns property. */ - private _setOptionIds() { - this._optionIds = this.options.map((option) => option.id).join(' '); + private setOptionIds() { + this.optionIds = this.options.map((option) => option.id).join(' '); } /** * Highlights the selected item. If no option is selected, it will highlight * the first item instead. */ - private _highlightCorrectOption(): void { - if (this._keyManager) { + private highlightCorrectOption(): void { + if (this.keyManager) { if (this.empty) { - this._keyManager.setFirstItemActive(); + this.keyManager.setFirstItemActive(); } else { - this._keyManager.setActiveItem(this._selectionModel.selected[0]); + this.keyManager.setActiveItem(this.selectionModel.selected[0]); } } } /** Scrolls the active option into view. */ - private _scrollActiveOptionIntoView(): void { - const activeOptionIndex = this._keyManager.activeItemIndex || 0; - const labelCount = _countGroupLabelsBeforeOption(activeOptionIndex, this.options, this.optionGroups); + private scrollActiveOptionIntoView(): void { + const activeOptionIndex = this.keyManager.activeItemIndex || 0; + const labelCount = countGroupLabelsBeforeOption(activeOptionIndex, this.options, this.optionGroups); - this.panel.nativeElement.scrollTop = _getOptionScrollPosition( + this.panel.nativeElement.scrollTop = getOptionScrollPosition( activeOptionIndex + labelCount, - this._getItemHeight(), + this.getItemHeight(), this.panel.nativeElement.scrollTop, SELECT_PANEL_MAX_HEIGHT ); } /** Gets the index of the provided option in the option list. */ - private _getOptionIndex(option: McOption): number | undefined { + private getOptionIndex(option: McOption): number | undefined { /* tslint:disable-next-line */ return this.options.reduce((result: number, current: McOption, index: number) => { /* tslint:disable-next-line:strict-type-predicates */ @@ -1152,9 +1087,9 @@ export class McSelect extends _McSelectMixinBase implements } /** Calculates the scroll position and x- and y-offsets of the overlay panel. */ - private _calculateOverlayPosition(): void { - const itemHeight = this._getItemHeight(); - const items = this._getItemCount(); + private calculateOverlayPosition(): void { + const itemHeight = this.getItemHeight(); + const items = this.getItemCount(); const panelHeight = Math.min(items * itemHeight, SELECT_PANEL_MAX_HEIGHT); const scrollContainerHeight = items * itemHeight; @@ -1163,19 +1098,18 @@ export class McSelect extends _McSelectMixinBase implements // If no value is selected we open the popup to the first item. let selectedOptionOffset = - this.empty ? 0 : this._getOptionIndex(this._selectionModel.selected[0])!; + this.empty ? 0 : this.getOptionIndex(this.selectionModel.selected[0])!; - selectedOptionOffset += _countGroupLabelsBeforeOption(selectedOptionOffset, this.options, - this.optionGroups); + selectedOptionOffset += countGroupLabelsBeforeOption(selectedOptionOffset, this.options, this.optionGroups); // We must maintain a scroll buffer so the selected option will be scrolled to the // center of the overlay panel rather than the top. /* tslint:disable-next-line:no-magic-numbers */ const scrollBuffer = panelHeight / 2; - this._scrollTop = this._calculateOverlayScroll(selectedOptionOffset, scrollBuffer, maxScroll); - this._offsetY = this._calculateOverlayOffsetY(); + this.scrollTop = this.calculateOverlayScroll(selectedOptionOffset, scrollBuffer, maxScroll); + this.offsetY = this.calculateOverlayOffsetY(); - this._checkOverlayWithinViewport(maxScroll); + this.checkOverlayWithinViewport(maxScroll); } /** @@ -1185,15 +1119,15 @@ export class McSelect extends _McSelectMixinBase implements * can't be calculated until the panel has been attached, because we need to know the * content width in order to constrain the panel within the viewport. */ - private _calculateOverlayOffsetX(): void { + private calculateOverlayOffsetX(): void { const overlayRect = this.overlayDir.overlayRef.overlayElement.getBoundingClientRect(); const viewportSize = this._viewportRuler.getViewportSize(); - const isRtl = this._isRtl(); + const isRtl = this.isRtl(); /* tslint:disable-next-line:no-magic-numbers */ const paddingWidth = SELECT_PANEL_PADDING_X * 2; let offsetX: number; - const selected = this._selectionModel.selected[0] || this.options.first; + const selected = this.selectionModel.selected[0] || this.options.first; offsetX = selected && selected.group ? SELECT_PANEL_INDENT_PADDING_X : SELECT_PANEL_PADDING_X; // Invert the offset in LTR. @@ -1223,9 +1157,9 @@ export class McSelect extends _McSelectMixinBase implements * top start corner of the trigger. It has to be adjusted in order for the * selected option to be aligned over the trigger when the panel opens. */ - private _calculateOverlayOffsetY(): number { - // const itemHeight = this._getItemHeight(); - // const optionHeightAdjustment = (itemHeight - this._triggerRect.height) / 2; + private calculateOverlayOffsetY(): number { + // const itemHeight = this.getItemHeight(); + // const optionHeightAdjustment = (itemHeight - this.triggerRect.height) / 2; // todo I'm not sure that we will use it return 0; @@ -1238,93 +1172,94 @@ export class McSelect extends _McSelectMixinBase implements * y-offset so the panel can open fully on-screen. If it still won't fit, * sets the offset back to 0 to allow the fallback position to take over. */ - private _checkOverlayWithinViewport(maxScroll: number): void { - const itemHeight = this._getItemHeight(); + private checkOverlayWithinViewport(maxScroll: number): void { + const itemHeight = this.getItemHeight(); const viewportSize = this._viewportRuler.getViewportSize(); - const topSpaceAvailable = this._triggerRect.top - SELECT_PANEL_VIEWPORT_PADDING; + const topSpaceAvailable = this.triggerRect.top - SELECT_PANEL_VIEWPORT_PADDING; const bottomSpaceAvailable = - viewportSize.height - this._triggerRect.bottom - SELECT_PANEL_VIEWPORT_PADDING; + viewportSize.height - this.triggerRect.bottom - SELECT_PANEL_VIEWPORT_PADDING; - const panelHeightTop = Math.abs(this._offsetY); + const panelHeightTop = Math.abs(this.offsetY); const totalPanelHeight = - Math.min(this._getItemCount() * itemHeight, SELECT_PANEL_MAX_HEIGHT); - const panelHeightBottom = totalPanelHeight - panelHeightTop - this._triggerRect.height; + Math.min(this.getItemCount() * itemHeight, SELECT_PANEL_MAX_HEIGHT); + const panelHeightBottom = totalPanelHeight - panelHeightTop - this.triggerRect.height; if (panelHeightBottom > bottomSpaceAvailable) { - this._adjustPanelUp(panelHeightBottom, bottomSpaceAvailable); + this.adjustPanelUp(panelHeightBottom, bottomSpaceAvailable); } else if (panelHeightTop > topSpaceAvailable) { - this._adjustPanelDown(panelHeightTop, topSpaceAvailable, maxScroll); + this.adjustPanelDown(panelHeightTop, topSpaceAvailable, maxScroll); } else { - this._transformOrigin = this._getOriginBasedOnOption(); + this.transformOrigin = this.getOriginBasedOnOption(); } } /** Adjusts the overlay panel up to fit in the viewport. */ - private _adjustPanelUp(panelHeightBottom: number, bottomSpaceAvailable: number) { + private adjustPanelUp(panelHeightBottom: number, bottomSpaceAvailable: number) { // Browsers ignore fractional scroll offsets, so we need to round. const distanceBelowViewport = Math.round(panelHeightBottom - bottomSpaceAvailable); // Scrolls the panel up by the distance it was extending past the boundary, then // adjusts the offset by that amount to move the panel up into the viewport. - this._scrollTop -= distanceBelowViewport; - this._offsetY -= distanceBelowViewport; - this._transformOrigin = this._getOriginBasedOnOption(); + this.scrollTop -= distanceBelowViewport; + this.offsetY -= distanceBelowViewport; + this.transformOrigin = this.getOriginBasedOnOption(); // If the panel is scrolled to the very top, it won't be able to fit the panel // by scrolling, so set the offset to 0 to allow the fallback position to take // effect. - if (this._scrollTop <= 0) { - this._scrollTop = 0; - this._offsetY = 0; - this._transformOrigin = `50% bottom 0px`; + if (this.scrollTop <= 0) { + this.scrollTop = 0; + this.offsetY = 0; + this.transformOrigin = `50% bottom 0px`; } } /** Adjusts the overlay panel down to fit in the viewport. */ - private _adjustPanelDown(panelHeightTop: number, topSpaceAvailable: number, maxScroll: number) { + private adjustPanelDown(panelHeightTop: number, topSpaceAvailable: number, maxScroll: number) { // Browsers ignore fractional scroll offsets, so we need to round. const distanceAboveViewport = Math.round(panelHeightTop - topSpaceAvailable); // Scrolls the panel down by the distance it was extending past the boundary, then // adjusts the offset by that amount to move the panel down into the viewport. - this._scrollTop += distanceAboveViewport; - this._offsetY += distanceAboveViewport; - this._transformOrigin = this._getOriginBasedOnOption(); + this.scrollTop += distanceAboveViewport; + this.offsetY += distanceAboveViewport; + this.transformOrigin = this.getOriginBasedOnOption(); // If the panel is scrolled to the very bottom, it won't be able to fit the // panel by scrolling, so set the offset to 0 to allow the fallback position // to take effect. - if (this._scrollTop >= maxScroll) { - this._scrollTop = maxScroll; - this._offsetY = 0; - this._transformOrigin = `50% top 0px`; + if (this.scrollTop >= maxScroll) { + this.scrollTop = maxScroll; + this.offsetY = 0; + this.transformOrigin = `50% top 0px`; return; } } /** Sets the transform origin point based on the selected option. */ - private _getOriginBasedOnOption(): string { - const itemHeight = this._getItemHeight(); + private getOriginBasedOnOption(): string { + const itemHeight = this.getItemHeight(); /* tslint:disable-next-line:no-magic-numbers */ - const optionHeightAdjustment = (itemHeight - this._triggerRect.height) / 2; + const optionHeightAdjustment = (itemHeight - this.triggerRect.height) / 2; /* tslint:disable-next-line:no-magic-numbers */ - const originY = Math.abs(this._offsetY) - optionHeightAdjustment + itemHeight / 2; + const originY = Math.abs(this.offsetY) - optionHeightAdjustment + itemHeight / 2; return `50% ${originY}px 0px`; } /** Calculates the amount of items in the select. This includes options and group labels. */ - private _getItemCount(): number { + private getItemCount(): number { return this.options.length + this.optionGroups.length; } /** Calculates the height of the select's options. */ - private _getItemHeight(): number { + private getItemHeight(): number { + // todo доделать /* tslint:disable-next-line:no-magic-numbers */ return 32; - // return this._triggerFontSize * SELECT_ITEM_HEIGHT_EM; + // return this.triggerFontSize * SELECT_ITEM_HEIGHT_EM; } /** Comparison function to specify which option is displayed. Defaults to object equality. */ diff --git a/src/lib/select/select.html b/src/lib/select/select.html index 70d8cffe2..b0a966e30 100644 --- a/src/lib/select/select.html +++ b/src/lib/select/select.html @@ -33,30 +33,30 @@ cdkConnectedOverlayLockPosition cdkConnectedOverlayHasBackdrop cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" - [cdkConnectedOverlayScrollStrategy]="_scrollStrategy" + [cdkConnectedOverlayScrollStrategy]="scrollStrategy" [cdkConnectedOverlayOrigin]="origin" [cdkConnectedOverlayOpen]="panelOpen" - [cdkConnectedOverlayPositions]="_positions" - [cdkConnectedOverlayMinWidth]="_triggerRect?.width" - [cdkConnectedOverlayOffsetY]="_offsetY" + [cdkConnectedOverlayPositions]="positions" + [cdkConnectedOverlayMinWidth]="triggerRect?.width" + [cdkConnectedOverlayOffsetY]="offsetY" (backdropClick)="close()" - (attach)="_onAttached()" + (attach)="onAttached()" (detach)="close()">
+ (@transformPanel.done)="panelDoneAnimatingStream.next($event.toState)" + [style.transformOrigin]="transformOrigin" + [class.mc-select-panel-done-animcing]="panelDoneAnimating" + [style.font-size.px]="triggerFontSize" + (keydown)="handleKeydown($event)">
+ (@fadeInContent.done)="onFadeInDone()">
diff --git a/src/lib/select/select.module.ts b/src/lib/select/select.module.ts index 70a18c847..bb50b8150 100644 --- a/src/lib/select/select.module.ts +++ b/src/lib/select/select.module.ts @@ -2,12 +2,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { OverlayModule } from '@ptsecurity/cdk/overlay'; -import { McOptionModule } from '@ptsecurity/mosaic/core'; +import { MC_SELECT_SCROLL_STRATEGY_PROVIDER, McOptionModule } from '@ptsecurity/mosaic/core'; import { McFormFieldModule } from '@ptsecurity/mosaic/form-field'; import { McIconModule } from '@ptsecurity/mosaic/icon'; import { McTagModule } from '@ptsecurity/mosaic/tag'; -import { MC_SELECT_SCROLL_STRATEGY_PROVIDER, McSelect, McSelectTrigger } from './select.component'; +import { McSelect, McSelectTrigger } from './select.component'; @NgModule({ diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index 32a556924..d9dab9ad9 100644 --- a/src/lib/select/select.scss +++ b/src/lib/select/select.scss @@ -1,8 +1,6 @@ @import '../core/styles/common/vendor-prefixes'; @import '../divider/divider'; -@import './select-theme'; - @import '../../cdk/a11y/a11y'; $mc-select-arrow-size: 5px !default; diff --git a/src/lib/tag/tag.scss b/src/lib/tag/tag.scss index 3e9dbbbc9..f15eb0c3c 100644 --- a/src/lib/tag/tag.scss +++ b/src/lib/tag/tag.scss @@ -4,7 +4,7 @@ $mc-tag-border-width: 1px; $mc-tag-border-radius: 4px; -$mc-tag-height: 24px; +$mc-tag-height: 22px; $mc-tag-icon-padding: 3px; diff --git a/src/lib/textarea/textarea.component.ts b/src/lib/textarea/textarea.component.ts index d54ba4a55..bf14fe44c 100644 --- a/src/lib/textarea/textarea.component.ts +++ b/src/lib/textarea/textarea.component.ts @@ -24,9 +24,9 @@ let nextUniqueId = 0; const ROW_SEPARATOR = '\n'; export class McTextareaBase { - constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, - public _parentForm: NgForm, - public _parentFormGroup: FormGroupDirective, + constructor(public defaultErrorStateMatcher: ErrorStateMatcher, + public parentForm: NgForm, + public parentFormGroup: FormGroupDirective, public ngControl: NgControl) { } } @@ -161,12 +161,12 @@ export class McTextarea extends McTextareaMixinBase implements McFormFieldContro constructor(protected elementRef: ElementRef, @Optional() @Self() public ngControl: NgControl, - @Optional() _parentForm: NgForm, - @Optional() _parentFormGroup: FormGroupDirective, - _defaultErrorStateMatcher: ErrorStateMatcher, + @Optional() parentForm: NgForm, + @Optional() parentFormGroup: FormGroupDirective, + defaultErrorStateMatcher: ErrorStateMatcher, @Optional() @Self() @Inject(MC_TEXTAREA_VALUE_ACCESSOR) inputValueAccessor: any, private ngZone: NgZone) { - super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + super(defaultErrorStateMatcher, parentForm, parentFormGroup, ngControl); // If no input value accessor was explicitly specified, use the element as the textarea value // accessor. this.valueAccessor = inputValueAccessor || this.elementRef.nativeElement; diff --git a/src/lib/timepicker/timepicker.ts b/src/lib/timepicker/timepicker.ts index 1c176f002..eafc776c4 100644 --- a/src/lib/timepicker/timepicker.ts +++ b/src/lib/timepicker/timepicker.ts @@ -66,12 +66,9 @@ const validatorOnChange = (c: FormControl) => { export class McTimepickerBase { constructor( - // tslint:disable-next-line naming-convention - public _defaultErrorStateMatcher: ErrorStateMatcher, - // tslint:disable-next-line naming-convention - public _parentForm: NgForm, - // tslint:disable-next-line naming-convention - public _parentFormGroup: FormGroupDirective, + public defaultErrorStateMatcher: ErrorStateMatcher, + public parentForm: NgForm, + public parentFormGroup: FormGroupDirective, public ngControl: NgControl) { } } @@ -261,15 +258,12 @@ export class McTimepicker extends McTimepickerMixinBase constructor(private readonly elementRef: ElementRef, @Optional() @Self() public ngControl: NgControl, - // tslint:disable-next-line naming-convention - @Optional() _parentForm: NgForm, - // tslint:disable-next-line naming-convention - @Optional() _parentFormGroup: FormGroupDirective, - // tslint:disable-next-line naming-convention - _defaultErrorStateMatcher: ErrorStateMatcher, + @Optional() parentForm: NgForm, + @Optional() parentFormGroup: FormGroupDirective, + defaultErrorStateMatcher: ErrorStateMatcher, @Optional() @Self() @Inject(MC_INPUT_VALUE_ACCESSOR) inputValueAccessor: any, private readonly renderer: Renderer2) { - super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + super(defaultErrorStateMatcher, parentForm, parentFormGroup, ngControl); // If no input value accessor was explicitly specified, use the element as the input value // accessor. diff --git a/src/lib/tree-select/README.md b/src/lib/tree-select/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/tree-select/_tree-select-theme.scss b/src/lib/tree-select/_tree-select-theme.scss new file mode 100644 index 000000000..0cadb00ed --- /dev/null +++ b/src/lib/tree-select/_tree-select-theme.scss @@ -0,0 +1,49 @@ +@import '../core/theming/theming'; +@import '../core/theming/palette'; +@import '../core/styles/typography/typography-utils'; + + +@mixin mc-tree-select-theme($theme) { + $primary: map-get($theme, primary); + $second: map-get($theme, second); + $error: map-get($theme, error); + + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + $is-dark: map-get($theme, is-dark); + + .mc-tree-select { + color: map-get($foreground, text); + + &.ng-invalid { + color: mc-color($error); + } + + &.mc-disabled { + color: mc-color($foreground, disabled-text); + } + } + + .mc-tree-select__placeholder { + color: mc-color($foreground, disabled-text); + } + + .mc-tree-select__panel { + border: { + color: mc-color($second); + } + + // todo A-black-200 нет в палитре + box-shadow: 0 3px 3px 0 rgba(0, 0, 0, 0.2); + + background-color: if($is-dark, map-get($second, 700), map-get($background, background)); + } +} + +@mixin mc-tree-select-typography($config) { + .mc-tree-select, + .mc-tree-select__panel { + @include mc-typography-level-to-styles($config, body); + } +} diff --git a/src/lib/tree-select/index.ts b/src/lib/tree-select/index.ts new file mode 100644 index 000000000..7e1a213e3 --- /dev/null +++ b/src/lib/tree-select/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/src/lib/tree-select/public-api.ts b/src/lib/tree-select/public-api.ts new file mode 100644 index 000000000..c2c853b7e --- /dev/null +++ b/src/lib/tree-select/public-api.ts @@ -0,0 +1,2 @@ +export * from './tree-select.module'; +export * from './tree-select.component'; diff --git a/src/lib/tree-select/tree-select.component.spec.ts b/src/lib/tree-select/tree-select.component.spec.ts new file mode 100644 index 000000000..f3a9adcc3 --- /dev/null +++ b/src/lib/tree-select/tree-select.component.spec.ts @@ -0,0 +1,4675 @@ +/* tslint:disable:no-magic-numbers */ +/* tslint:disable:mocha-no-side-effect-code */ +/* tslint:disable:no-non-null-assertion */ +/* tslint:disable:no-empty */ +/* tslint:disable:no-unbound-method */ +/* tslint:disable:prefer-for-of */ +// tslint:disable:max-func-body-length + +import { + ChangeDetectionStrategy, + Component, + DebugElement, + Injectable, + OnInit, + QueryList, + ViewChild, + ViewChildren +} from '@angular/core'; +import { + async, + ComponentFixture, + fakeAsync, + flush, + inject, + TestBed, + tick +} from '@angular/core/testing'; +import { + ControlValueAccessor, + FormControl, + FormGroup, + FormGroupDirective, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Directionality } from '@ptsecurity/cdk/bidi'; +import { + DOWN_ARROW, + END, + ENTER, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB, + UP_ARROW, + A +} from '@ptsecurity/cdk/keycodes'; +import { OverlayContainer } from '@ptsecurity/cdk/overlay'; +import { Platform } from '@ptsecurity/cdk/platform'; +import { ScrollDispatcher, ViewportRuler } from '@ptsecurity/cdk/scrolling'; +import { + createKeyboardEvent, + dispatchEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, + wrappedErrorMessage +} from '@ptsecurity/cdk/testing'; +import { FlatTreeControl } from '@ptsecurity/cdk/tree'; +import { + ErrorStateMatcher, getMcSelectDynamicMultipleError, + getMcSelectNonArrayValueError, + getMcSelectNonFunctionValueError +} from '@ptsecurity/mosaic/core'; +import { McFormFieldModule } from '@ptsecurity/mosaic/form-field'; +import { + McTreeFlatDataSource, + McTreeFlattener, + McTreeModule, + McTreeOption, + McTreeSelectionChange +} from '@ptsecurity/mosaic/tree'; +import { BehaviorSubject, Observable, of as observableOf, Subject, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; + + +const TREE_DATA = ` + { + "rootNode_1": "app", + "Pictures": { + "Sun": "png", + "Woods": "jpg", + "Photo Booth Library": { + "Contents": "dir", + "Pictures": "dir" + } + }, + "Documents": { + "angular": { + "src": { + "core": "ts", + "compiler": "ts" + } + }, + "material2": { + "src": { + "button": "ts", + "checkbox": "ts", + "input": "ts" + } + } + }, + "Downloads": { + "Tutorial": "html", + "November": "pdf", + "October": "pdf" + }, + "Applications": { + "Chrome": "app", + "Calendar": "app", + "Webstorm": "app" + } +}`; + +class FileNode { + children: FileNode[]; + name: string; + type: any; +} + +class FileFlatNode { + name: string; + type: any; + level: number; + expandable: boolean; +} + +@Injectable() +export class FileDatabase { + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): FileNode[] { return this.dataChange.value; } + + constructor() { + this.initialize(); + } + + initialize() { + // Parse the string to json object. + const dataObject = JSON.parse(TREE_DATA); + + // Build the tree nodes from Json object. The result is a list of `FileNode` with nested + // file node as children. + const data = this.buildFileTree(dataObject, 0); + + // Notify the change. + this.dataChange.next(data); + } + + /** + * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. + * The return value is the list of `FileNode`. + */ + buildFileTree(value: any, level: number): FileNode[] { + const data: any[] = []; + + for (const k of Object.keys(value)) { + const v = value[k]; + const node = new FileNode(); + + node.name = `${k}`; + + if (v === null || v === undefined) { + // no action + } else if (typeof v === 'object') { + node.children = this.buildFileTree(v, level + 1); + } else { + node.type = v; + } + + data.push(node); + } + + return data; + } +} + +import { McTreeSelectModule, McTreeSelect } from './index'; + + +/** The debounce interval when typing letters to select an option. */ +const LETTER_KEY_DEBOUNCE_INTERVAL = 200; + +const transformer = (node: FileNode, level: number) => { + const flatNode = new FileFlatNode(); + + flatNode.name = node.name; + flatNode.type = node.type; + flatNode.level = level; + flatNode.expandable = !!node.children; + + return flatNode; +}; + +const getLevel = (node: FileFlatNode) => node.level; + +const isExpandable = (node: FileFlatNode) => node.expandable; + +const getChildren = (node: FileNode): Observable => { + return observableOf(node.children); +}; + + +@Component({ + selector: 'basic-select', + template: ` +
+ + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + +
+ ` +}) +class BasicTreeSelect { + control = new FormControl(); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + isRequired: boolean; + heightAbove = 0; + heightBelow = 0; + tabIndexOverride: number; + panelClass = ['custom-one', 'custom-two']; + + @ViewChild(McTreeSelect) select: McTreeSelect; + @ViewChildren(McTreeOption) options: QueryList; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => { + this.dataSource.data = data; + }); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'ng-model-select', + template: ` + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class NgModelSelect { + isDisabled: boolean; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + @ViewChild(McTreeSelect) select: McTreeSelect; + @ViewChildren(McTreeOption) options: QueryList; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'many-selects', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class ManySelects { + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'ng-if-select', + template: ` +
+ + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + +
+ ` +}) +class NgIfSelect { + isShowing = false; + + control = new FormControl('rootNode_1'); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + @ViewChild(McTreeSelect) select: McTreeSelect; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'select-with-change-event', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class SelectWithChangeEvent { + changeListener = jasmine.createSpy('McTreeSelect change listener'); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'select-init-without-options', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class SelectInitWithoutOptions { + control = new FormControl('rootNode_1'); + + @ViewChild(McTreeSelect) select: McTreeSelect; + @ViewChildren(McTreeOption) options: QueryList; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'custom-select-accessor', + template: ` + + + `, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: CustomSelectAccessor, + multi: true + }] +}) +class CustomSelectAccessor implements ControlValueAccessor { + @ViewChild(McTreeSelect) select: McTreeSelect; + + writeValue: (value?: any) => void = () => {}; + registerOnChange: (changeFn?: (value: any) => void) => void = () => {}; + registerOnTouched: (touchedFn?: () => void) => void = () => {}; +} + +@Component({ + selector: 'comp-with-custom-select', + template: ` + `, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: CustomSelectAccessor, + multi: true + }] +}) +class CompWithCustomSelect { + ctrl = new FormControl('initial value'); + @ViewChild(CustomSelectAccessor) customAccessor: CustomSelectAccessor; +} + +@Component({ + selector: 'select-infinite-loop', + template: ` + + + + + ` +}) +class SelectWithErrorSibling { + value: string; +} + +@Component({ + selector: 'throws-error-on-init', + template: '' +}) +class ThrowsErrorOnInit implements OnInit { + ngOnInit() { + throw Error('Oh no!'); + } +} + +@Component({ + selector: 'basic-select-on-push', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class BasicSelectOnPush { + control = new FormControl(); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'basic-select-on-push-preselected', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class BasicSelectOnPushPreselected { + control = new FormControl('rootNode_1'); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'multi-select', + template: ` + + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class MultiSelect { + control = new FormControl(); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + @ViewChild(McTreeSelect) select: McTreeSelect; + + @ViewChildren(McTreeOption) options: QueryList; + + sortComparator: (a: McTreeOption, b: McTreeOption, options: McTreeOption[]) => number; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'select-with-plain-tabindex', + template: ` + + + ` +}) +class SelectWithPlainTabindex {} + +@Component({ + selector: 'select-early-sibling-access', + template: ` + + + +
+ ` +}) +class SelectEarlyAccessSibling {} + +@Component({ + selector: 'basic-select-initially-hidden', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class BasicSelectInitiallyHidden { + isVisible = false; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'basic-select-no-placeholder', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class BasicSelectNoPlaceholder { + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'basic-select-with-theming', + template: ` + + + + + Steak + Pizza + + + + ` +}) +class BasicSelectWithTheming { + @ViewChild(McTreeSelect) select: McTreeSelect; + theme: string; +} + +@Component({ + selector: 'reset-values-select', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class ResetValuesSelect { + control = new FormControl(); + + @ViewChild(McTreeSelect) select: McTreeSelect; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class FalsyValueSelect { + @ViewChildren(McTreeOption) options: QueryList; + + control = new FormControl(); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + template: ` +
+ + + +
+ ` +}) +class InvalidSelectInForm { + value: any; +} + +@Component({ + template: ` +
+ + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + +
+ ` +}) +class SelectInsideFormGroup { + @ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective; + @ViewChild(McTreeSelect) select: McTreeSelect; + + formControl = new FormControl('', Validators.required); + formGroup = new FormGroup({ + food: this.formControl + }); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class BasicSelectWithoutForms { + selectedFood: string | null; + + @ViewChild(McTreeSelect) select: McTreeSelect; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class BasicSelectWithoutFormsPreselected { + selectedFood = 'Pictures'; + + @ViewChild(McTreeSelect) select: McTreeSelect; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class BasicSelectWithoutFormsMultiple { + selectedFoods: string[]; + + @ViewChild(McTreeSelect) select: McTreeSelect; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'select-with-custom-trigger', + template: ` + + + + {{ select.selected?.viewValue.split('').reverse().join('') }} + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class SelectWithCustomTrigger { + control = new FormControl(); + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'ng-model-compare-with', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class NgModelCompareWithSelect { + selectedFood: { name: string; type: string } = { name: 'rootNode_1', type: 'app' }; + comparator: ((f1: any, f2: any) => boolean) | null = this.compareByValue; + + @ViewChild(McTreeSelect) select: McTreeSelect; + @ViewChildren(McTreeOption) options: QueryList; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } + + useCompareByValue() { + this.comparator = this.compareByValue; + } + + useCompareByReference() { + this.comparator = this.compareByReference; + } + + useNullComparator() { + this.comparator = null; + } + + compareByValue(f1: any, f2: any) { + return f1 && f2 && f1.value === f2.value; + } + + compareByReference(f1: any, f2: any) { + return f1 === f2; + } + + setFoodByCopy(newValue: { name: string; type: string }) { + this.selectedFood = { ...{}, ...newValue }; + } +} + +@Component({ + template: ` + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + ` +}) +class CustomErrorBehaviorSelect { + @ViewChild(McTreeSelect) select: McTreeSelect; + + control = new FormControl(); + + errorStateMatcher: ErrorStateMatcher; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class SingleSelectWithPreselectedArrayValues { + selectedFood = { name: 'Pictures', type: 'app' }; + + @ViewChild(McTreeSelect) select: McTreeSelect; + @ViewChildren(McTreeOption) options: QueryList; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + selector: 'select-without-option-centering', + template: ` + + + + + + {{ node.name }} + + + + + {{ node.name }} : {{ node.type }} + + + + + ` +}) +class SelectWithoutOptionCentering { + control = new FormControl('rootNode_1'); + + @ViewChild(McTreeSelect) select: McTreeSelect; + @ViewChildren(McTreeOption) options: QueryList; + + treeControl = new FlatTreeControl(getLevel, isExpandable); + treeFlattener = new McTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: McTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.dataSource = new McTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe((data) => this.dataSource.data = data); + } + + hasChild(_: number, nodeData: FileFlatNode) { + return nodeData.expandable; + } +} + +@Component({ + template: ` + + + + + + + A thing + + + + ` +}) +class SelectWithFormFieldLabel { + placeholder: string; +} + +describe('McTreeSelect', () => { + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let dir: { value: 'ltr' | 'rtl' }; + const scrolledSubject: Subject = new Subject(); + let viewportRuler: ViewportRuler; + let platform: Platform; + + /** + * Configures the test module for McTreeSelect with the given declarations. This is broken out so + * that we're only compiling the necessary test components for each test in order to speed up + * overall test time. + * @param declarations Components to declare for this block + */ + function configureMcTreeSelectTestingModule(declarations: any[]) { + TestBed.configureTestingModule({ + imports: [ + McFormFieldModule, + McTreeModule, + McTreeSelectModule, + ReactiveFormsModule, + FormsModule, + NoopAnimationsModule + ], + declarations, + providers: [ + FileDatabase, + { provide: Directionality, useFactory: () => dir = { value: 'ltr' } }, + { provide: ScrollDispatcher, useFactory: () => ({ + scrolled: () => scrolledSubject.asObservable() + }) + } + ] + }).compileComponents(); + + inject([OverlayContainer, Platform], (oc: OverlayContainer, p: Platform) => { + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + platform = p; + })(); + } + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + describe('core', () => { + beforeEach(async(() => { + configureMcTreeSelectTestingModule([ + BasicTreeSelect, + MultiSelect, + SelectWithFormFieldLabel, + SelectWithChangeEvent + ]); + })); + + describe('accessibility', () => { + describe('for select', () => { + let fixture: ComponentFixture; + let select: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicTreeSelect); + fixture.detectChanges(); + select = fixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + })); + + it('should set the tabindex of the select to 0 by default', fakeAsync(() => { + expect(select.getAttribute('tabindex')).toEqual('0'); + })); + + it('should be able to override the tabindex', fakeAsync(() => { + fixture.componentInstance.tabIndexOverride = 3; + fixture.detectChanges(); + + expect(select.getAttribute('tabindex')).toBe('3'); + })); + + it('should set the mc-select-required class for required selects', fakeAsync(() => { + expect(select.classList).not.toContain( + 'mc-select-required', `Expected the mc-select-required class not to be set.`); + + fixture.componentInstance.isRequired = true; + fixture.detectChanges(); + + expect(select.classList).toContain( + 'mc-select-required', `Expected the mc-select-required class to be set.`); + })); + + it('should set the tabindex of the select to -1 if disabled', fakeAsync(() => { + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + expect(select.getAttribute('tabindex')).toEqual('-1'); + + fixture.componentInstance.control.enable(); + fixture.detectChanges(); + expect(select.getAttribute('tabindex')).toEqual('0'); + })); + + xit('should select options via the UP/DOWN arrow keys on a closed select', fakeAsync(() => { + const formControl = fixture.componentInstance.control; + const options = fixture.componentInstance.options.toArray(); + + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(options[0].selected).toBe(true, 'Expected first option to be selected.'); + expect(formControl.value).toBe(options[0].value, + 'Expected value from first option to have been set on the model.'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + // Note that the third option is skipped, because it is disabled. + expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.'); + expect(formControl.value).toBe(options[3].value, + 'Expected value from fourth option to have been set on the model.'); + + dispatchKeyboardEvent(select, 'keydown', UP_ARROW); + + expect(options[1].selected).toBe(true, 'Expected second option to be selected.'); + expect(formControl.value).toBe(options[1].value, + 'Expected value from second option to have been set on the model.'); + })); + + it('should resume focus from selected item after selecting via click', fakeAsync(() => { + const formControl = fixture.componentInstance.control; + const options = fixture.componentInstance.options.toArray(); + + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + fixture.componentInstance.select.open(); + fixture.detectChanges(); + flush(); + + (overlayContainerElement.querySelectorAll('mc-tree-option')[2] as HTMLElement).click(); + fixture.detectChanges(); + flush(); + + expect(formControl.value).toBe(options[2].value); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(formControl.value).toBe(options[4].value); + })); + + xit('should select options via LEFT/RIGHT arrow keys on a closed select', fakeAsync(() => { + const formControl = fixture.componentInstance.control; + const options = fixture.componentInstance.options.toArray(); + + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW); + + expect(options[0].selected).toBe(true, 'Expected first option to be selected.'); + expect(formControl.value).toBe(options[0].value, + 'Expected value from first option to have been set on the model.'); + + dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW); + dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW); + + // Note that the third option is skipped, because it is disabled. + expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.'); + expect(formControl.value).toBe(options[3].value, + 'Expected value from fourth option to have been set on the model.'); + + dispatchKeyboardEvent(select, 'keydown', LEFT_ARROW); + + expect(options[1].selected).toBe(true, 'Expected second option to be selected.'); + expect(formControl.value).toBe(options[1].value, + 'Expected value from second option to have been set on the model.'); + })); + + it('should open a single-selection select using ALT + DOWN_ARROW', fakeAsync(() => { + const { control: formControl, select: selectInstance } = fixture.componentInstance; + + expect(selectInstance.panelOpen).toBe(false, 'Expected select to be closed.'); + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + const event = createKeyboardEvent('keydown', DOWN_ARROW); + Object.defineProperty(event, 'altKey', { get: () => true }); + + dispatchEvent(select, event); + + expect(selectInstance.panelOpen).toBe(true, 'Expected select to be open.'); + expect(formControl.value).toBeFalsy('Expected value not to have changed.'); + })); + + it('should open a single-selection select using ALT + UP_ARROW', fakeAsync(() => { + const { control: formControl, select: selectInstance } = fixture.componentInstance; + + expect(selectInstance.panelOpen).toBe(false, 'Expected select to be closed.'); + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + const event = createKeyboardEvent('keydown', UP_ARROW); + Object.defineProperty(event, 'altKey', { get: () => true }); + + dispatchEvent(select, event); + + expect(selectInstance.panelOpen).toBe(true, 'Expected select to be open.'); + expect(formControl.value).toBeFalsy('Expected value not to have changed.'); + })); + + it('should should close when pressing ALT + DOWN_ARROW', fakeAsync(() => { + const { select: selectInstance } = fixture.componentInstance; + + selectInstance.open(); + + expect(selectInstance.panelOpen).toBe(true, 'Expected select to be open.'); + + const event = createKeyboardEvent('keydown', DOWN_ARROW); + Object.defineProperty(event, 'altKey', { get: () => true }); + + dispatchEvent(select, event); + + expect(selectInstance.panelOpen).toBe(false, 'Expected select to be closed.'); + expect(event.defaultPrevented).toBe(true, 'Expected default action to be prevented.'); + })); + + it('should should close when pressing ALT + UP_ARROW', fakeAsync(() => { + const { select: selectInstance } = fixture.componentInstance; + + selectInstance.open(); + + expect(selectInstance.panelOpen).toBe(true, 'Expected select to be open.'); + + const event = createKeyboardEvent('keydown', UP_ARROW); + Object.defineProperty(event, 'altKey', { get: () => true }); + + dispatchEvent(select, event); + + expect(selectInstance.panelOpen).toBe(false, 'Expected select to be closed.'); + expect(event.defaultPrevented).toBe(true, 'Expected default action to be prevented.'); + })); + + xit('should be able to select options by typing on a closed select', fakeAsync(() => { + const formControl = fixture.componentInstance.control; + const options = fixture.componentInstance.options.toArray(); + + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + dispatchEvent(select, createKeyboardEvent('keydown', 80, undefined, 'p')); + tick(200); + + expect(options[1].selected).toBe(true, 'Expected second option to be selected.'); + expect(formControl.value).toBe(options[1].value, + 'Expected value from second option to have been set on the model.'); + + dispatchEvent(select, createKeyboardEvent('keydown', 69, undefined, 'e')); + tick(200); + + expect(options[5].selected).toBe(true, 'Expected sixth option to be selected.'); + expect(formControl.value).toBe(options[5].value, + 'Expected value from sixth option to have been set on the model.'); + })); + + it('should open the panel when pressing a vertical arrow key on a closed multiple select', + fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + const instance = multiFixture.componentInstance; + + multiFixture.detectChanges(); + + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + + const initialValue = instance.control.value; + + expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.'); + + const event = dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(instance.select.panelOpen).toBe(true, 'Expected panel to be open.'); + expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.'); + expect(event.defaultPrevented).toBe(true, 'Expected default to be prevented.'); + })); + + it('should open the panel when pressing a horizontal arrow key on closed multiple select', + fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + const instance = multiFixture.componentInstance; + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + + const initialValue = instance.control.value; + + expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.'); + + const event = dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW); + + expect(instance.select.panelOpen).toBe(true, 'Expected panel to be open.'); + expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.'); + expect(event.defaultPrevented).toBe(true, 'Expected default to be prevented.'); + })); + + it('should do nothing when typing on a closed multi-select', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + const instance = multiFixture.componentInstance; + + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + + const initialValue = instance.control.value; + + expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.'); + + dispatchEvent(select, createKeyboardEvent('keydown', 80, undefined, 'p')); + + expect(instance.select.panelOpen).toBe(false, 'Expected panel to stay closed.'); + expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.'); + })); + + it('should do nothing if the key manager did not change the active item', fakeAsync(() => { + const formControl = fixture.componentInstance.control; + + expect(formControl.value).toBeNull('Expected form control value to be empty.'); + expect(formControl.pristine).toBe(true, 'Expected form control to be clean.'); + + dispatchKeyboardEvent(select, 'keydown', 16); // Press a random key. + + expect(formControl.value).toBeNull('Expected form control value to stay empty.'); + expect(formControl.pristine).toBe(true, 'Expected form control to stay clean.'); + })); + + it('should continue from the selected option when the value is set programmatically', + fakeAsync(() => { + const formControl = fixture.componentInstance.control; + + formControl.setValue('Pictures'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(formControl.value).toBe('Documents'); + expect(fixture.componentInstance.options.toArray()[2].active).toBe(true); + })); + + it('should not shift focus when the selected options are updated programmatically ' + + 'in a multi select', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + multiFixture.detectChanges(); + + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + multiFixture.componentInstance.select.open(); + multiFixture.detectChanges(); + flush(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-tree-option'); + + options[2].focus(); + expect(document.activeElement).toBe(options[2], 'Expected third option to be focused.'); + + multiFixture.componentInstance.control.setValue(['steak-0', 'sushi-7']); + + expect(document.activeElement) + .toBe(options[2], 'Expected fourth option to remain focused.'); + })); + + it('should not cycle through the options if the control is disabled', fakeAsync(() => { + const formControl = fixture.componentInstance.control; + + formControl.setValue('eggs-5'); + formControl.disable(); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(formControl.value).toBe('eggs-5', 'Expected value to remain unchaged.'); + })); + + it('should not wrap selection after reaching the end of the options', fakeAsync(() => { + const lastOption = fixture.componentInstance.options.last; + + fixture.componentInstance.options.forEach(() => { + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + }); + + expect(lastOption.selected).toBe(true, 'Expected last option to be selected.'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.'); + })); + + it('should not open a multiple select when tabbing through', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + + expect(multiFixture.componentInstance.select.panelOpen) + .toBe(false, 'Expected panel to be closed initially.'); + + dispatchKeyboardEvent(select, 'keydown', TAB); + + expect(multiFixture.componentInstance.select.panelOpen) + .toBe(false, 'Expected panel to stay closed.'); + })); + + xit('should toggle the next option when pressing shift + DOWN_ARROW on a multi-select', + fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + + const event = createKeyboardEvent('keydown', DOWN_ARROW); + Object.defineProperty(event, 'shiftKey', { get: () => true }); + + // multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + + multiFixture.componentInstance.select.open(); + multiFixture.detectChanges(); + flush(); + + expect(multiFixture.componentInstance.select.value).toBeFalsy(); + + dispatchEvent(select, event); + expect(multiFixture.componentInstance.select.value).toEqual(['pizza-1']); + + dispatchEvent(select, event); + expect(multiFixture.componentInstance.select.value).toEqual(['pizza-1', 'tacos-2']); + })); + + xit('should toggle the previous option when pressing shift + UP_ARROW on a multi-select', + fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + const event = createKeyboardEvent('keydown', UP_ARROW); + Object.defineProperty(event, 'shiftKey', { get: () => true }); + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + + multiFixture.componentInstance.select.open(); + multiFixture.detectChanges(); + flush(); + + // Move focus down first. + for (let i = 0; i < 5; i++) { + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + multiFixture.detectChanges(); + } + + expect(multiFixture.componentInstance.select.value).toBeFalsy(); + + dispatchEvent(select, event); + expect(multiFixture.componentInstance.select.value).toEqual(['chips-4']); + + dispatchEvent(select, event); + expect(multiFixture.componentInstance.select.value).toEqual(['sandwich-3', 'chips-4']); + })); + + it('should prevent the default action when pressing space', fakeAsync(() => { + const event = dispatchKeyboardEvent(select, 'keydown', SPACE); + expect(event.defaultPrevented).toBe(true); + })); + + it('should consider the selection a result of a user action when closed', fakeAsync(() => { + const option = fixture.componentInstance.options.first; + const spy = jasmine.createSpy('option selection spy'); + const subscription = + option.onSelectionChange.pipe(map((e) => e.isUserInput)).subscribe(spy); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + expect(spy).toHaveBeenCalledWith(true); + + subscription.unsubscribe(); + })); + + it('should be able to focus the select trigger', fakeAsync(() => { + document.body.focus(); // ensure that focus isn't on the trigger already + + fixture.componentInstance.select.focus(); + + expect(document.activeElement).toBe(select, 'Expected select element to be focused.'); + })); + + it('should restore focus to the trigger after selecting an option in multi-select mode', + fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + const instance = multiFixture.componentInstance; + + multiFixture.detectChanges(); + flush(); + + select = multiFixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + instance.select.open(); + multiFixture.detectChanges(); + flush(); + + // Ensure that the select isn't focused to begin with. + select.blur(); + expect(document.activeElement).not.toBe(select, 'Expected trigger not to be focused.'); + + const option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + expect(document.activeElement).toBe(select, 'Expected trigger to be focused.'); + })); + }); + + describe('for options', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + let options: NodeListOf; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicTreeSelect); + fixture.detectChanges(); + + trigger = fixture.debugElement.query(By.css('.mc-tree-select__trigger')).nativeElement; + + trigger.click(); + fixture.detectChanges(); + flush(); + + options = overlayContainerElement.querySelectorAll('mc-tree-option'); + })); + + it('should set the tabindex of each option according to disabled state', fakeAsync(() => { + expect(options[0].getAttribute('tabindex')).toEqual('0'); + expect(options[1].getAttribute('tabindex')).toEqual('0'); + expect(options[3].getAttribute('tabindex')).toEqual('-1'); + })); + }); + }); + + describe('overlay panel', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicTreeSelect); + fixture.detectChanges(); + trigger = fixture.debugElement.query(By.css('.mc-tree-select__trigger')).nativeElement; + })); + + it('should not throw when attempting to open too early', () => { + // Create component and then immediately open without running change detection + fixture = TestBed.createComponent(BasicTreeSelect); + expect(() => fixture.componentInstance.select.open()).not.toThrow(); + }); + + it('should open the panel when trigger is clicked', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + expect(overlayContainerElement.textContent).toContain('rootNode_1'); + expect(overlayContainerElement.textContent).toContain('Pictures'); + expect(overlayContainerElement.textContent).toContain('Documents'); + })); + + it('should close the panel when an item is clicked', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should close the panel when a click occurs outside the panel', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + + backdrop.click(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should set the width of the overlay based on the trigger', fakeAsync(() => { + trigger.style.width = '200px'; + + trigger.click(); + fixture.detectChanges(); + flush(); + + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + expect(pane.style.minWidth).toBe('200px'); + })); + + it('should not attempt to open a select that does not have any options', fakeAsync(() => { + fixture.componentInstance.dataSource.data = []; + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should close the panel when tabbing out', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + + dispatchKeyboardEvent(trigger, 'keydown', TAB); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should restore focus to the host before tabbing away', fakeAsync(() => { + const select = fixture.nativeElement.querySelector('.mc-tree-select'); + + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + + // Use a spy since focus can be flaky in unit tests. + spyOn(select, 'focus').and.callThrough(); + + dispatchKeyboardEvent(trigger, 'keydown', TAB); + fixture.detectChanges(); + flush(); + + expect(select.focus).toHaveBeenCalled(); + })); + + it('should close when tabbing out from inside the panel', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + + const panel = overlayContainerElement.querySelector('.mc-tree-select__panel')!; + dispatchKeyboardEvent(panel, 'keydown', TAB); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should focus the first option when pressing HOME', fakeAsync(() => { + fixture.componentInstance.control.setValue('Applications'); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const event = dispatchKeyboardEvent(trigger, 'keydown', HOME); + fixture.detectChanges(); + + expect(fixture.componentInstance.select.tree.keyManager.activeItemIndex).toBe(0); + expect(event.defaultPrevented).toBe(true); + })); + + it('should focus the last option when pressing END', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + fixture.componentInstance.control.setValue('rootNode_1'); + fixture.detectChanges(); + flush(); + + const event = dispatchKeyboardEvent(trigger, 'keydown', END); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.tree.keyManager.activeItemIndex).toBe(4); + expect(event.defaultPrevented).toBe(true); + })); + + it('should be able to set extra classes on the panel', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const panel = overlayContainerElement.querySelector('.mc-tree-select__panel') as HTMLElement; + + expect(panel.classList).toContain('custom-one'); + expect(panel.classList).toContain('custom-two'); + })); + + it('should prevent the default action when pressing SPACE on an option', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mc-tree-option') as Node; + const event = dispatchKeyboardEvent(option, 'keydown', SPACE); + + expect(event.defaultPrevented).toBe(true); + })); + + it('should prevent the default action when pressing ENTER on an option', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mc-tree-option') as Node; + const event = dispatchKeyboardEvent(option, 'keydown', ENTER); + + expect(event.defaultPrevented).toBe(true); + })); + + it('should not consider itself as blurred if the trigger loses focus while the ' + + 'panel is still open', fakeAsync(() => { + const selectElement = fixture.nativeElement.querySelector('.mc-tree-select'); + const selectInstance = fixture.componentInstance.select; + + dispatchFakeEvent(selectElement, 'focus'); + fixture.detectChanges(); + + /* tslint:disable-next-line:deprecation */ + expect(selectInstance.focused).toBe(true, 'Expected select to be focused.'); + + selectInstance.open(); + fixture.detectChanges(); + flush(); + dispatchFakeEvent(selectElement, 'blur'); + fixture.detectChanges(); + + /* tslint:disable-next-line:deprecation */ + expect(selectInstance.focused).toBe(true, 'Expected select element to remain focused.'); + })); + }); + + describe('selection logic', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicTreeSelect); + fixture.detectChanges(); + trigger = fixture.debugElement.query(By.css('.mc-tree-select__trigger')).nativeElement; + })); + + it('should focus the first option if no option is selected', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.tree.keyManager.activeItemIndex).toEqual(0); + })); + + it('should select an option when it is clicked', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + let option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + + expect(option.classList).toContain('mc-selected'); + expect(fixture.componentInstance.options.first.selected).toBe(true); + expect(fixture.componentInstance.select.selected) + .toBe(fixture.componentInstance.options.first); + })); + + xit('should be able to select an option using the McTreeOption API', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const optionInstances = fixture.componentInstance.options.toArray(); + const optionNodes: NodeListOf = overlayContainerElement.querySelectorAll('mc-tree-option'); + + optionInstances[1].select(); + fixture.detectChanges(); + flush(); + + expect(optionNodes[1].classList).toContain('mc-selected'); + expect(optionInstances[1].selected).toBe(true); + expect(fixture.componentInstance.select.selected).toBe(optionInstances[1]); + })); + + it('should deselect other options when one is selected', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + let options: NodeListOf = overlayContainerElement.querySelectorAll('mc-tree-option'); + + options[0].click(); + fixture.detectChanges(); + flush(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + options = overlayContainerElement.querySelectorAll('mc-tree-option'); + expect(options[1].classList).not.toContain('mc-selected'); + expect(options[2].classList).not.toContain('mc-selected'); + + const optionInstances = fixture.componentInstance.options.toArray(); + expect(optionInstances[1].selected).toBe(false); + expect(optionInstances[2].selected).toBe(false); + })); + + it('should deselect other options when one is programmatically selected', fakeAsync(() => { + const control = fixture.componentInstance.control; + const treeOptions = fixture.componentInstance.dataSource.data; + + trigger.click(); + fixture.detectChanges(); + flush(); + + let options: NodeListOf = overlayContainerElement.querySelectorAll('mc-tree-option'); + + options[0].click(); + fixture.detectChanges(); + flush(); + + control.setValue(treeOptions[1].name); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + options = overlayContainerElement.querySelectorAll('mc-tree-option'); + + expect(options[0].classList) + .not.toContain('mc-selected', 'Expected first option to no longer be selected'); + expect(options[1].classList) + .toContain('mc-selected', 'Expected second option to be selected'); + + const optionInstances = fixture.componentInstance.options.toArray(); + + expect(optionInstances[0].selected) + .toBe(false, 'Expected first option to no longer be selected'); + expect(optionInstances[1].selected) + .toBe(true, 'Expected second option to be selected'); + })); + + xit('should remove selection if option has been removed', fakeAsync(() => { + const select = fixture.componentInstance.select; + + trigger.click(); + fixture.detectChanges(); + flush(); + + const firstOption = overlayContainerElement.querySelectorAll('mc-tree-option')[0] as HTMLElement; + + firstOption.click(); + fixture.detectChanges(); + + expect(select.selected).toBe(select.options.first, 'Expected first option to be selected.'); + + fixture.componentInstance.dataSource.data = []; + fixture.detectChanges(); + flush(); + + // todo не очищается селект + expect(select.selected) + .toBeUndefined('Expected selection to be removed when option no longer exists.'); + })); + + it('should display the selected option in the trigger', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + const value = fixture.debugElement.query(By.css('.mc-tree-select__matcher')).nativeElement; + + expect(value.textContent).toContain('rootNode_1'); + })); + + it('should focus the selected option if an option is selected', fakeAsync(() => { + // must wait for initial writeValue promise to finish + flush(); + + fixture.componentInstance.control.setValue('Pictures'); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + // must wait for animation to finish + fixture.detectChanges(); + expect(fixture.componentInstance.select.tree.keyManager.activeItemIndex).toEqual(1); + })); + + xit('should select an option that was added after initialization', fakeAsync(() => { + // fixture.componentInstance.dataSource.data.push({ name: 'Potatoes', type: 'app' }); + trigger.click(); + fixture.detectChanges(); + flush(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll( + 'mc-tree-option' + ); + options[8].click(); + fixture.detectChanges(); + flush(); + + expect(trigger.textContent).toContain('Potatoes'); + expect(fixture.componentInstance.select.selected) + .toBe(fixture.componentInstance.options.last); + })); + + xit('should update the trigger when the selected option label is changed', fakeAsync(() => { + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + expect(trigger.querySelector('.mc-tree-select__matcher-text')!.textContent!.trim()) + .toBe('Pizza'); + + fixture.componentInstance.dataSource.data[1].name = 'Calzone'; + fixture.detectChanges(); + flush(); + + expect(trigger.querySelector('.mc-tree-select__matcher-text')!.textContent!.trim()) + .toBe('Calzone'); + })); + + it('should not select disabled options', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-tree-option'); + options[3].click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + expect(options[2].classList).not.toContain('mc-selected'); + expect(fixture.componentInstance.select.selected).toBeUndefined(); + })); + + it('should not throw if triggerValue accessed with no selected value', fakeAsync(() => { + expect(() => fixture.componentInstance.select.triggerValue).not.toThrow(); + })); + + xit('should emit to `optionSelectionChanges` when an option is selected', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const spy = jasmine.createSpy('option selection spy'); + const subscription = fixture.componentInstance.select.optionSelectionChanges.subscribe(spy); + const option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + expect(spy).toHaveBeenCalledWith(jasmine.any(McTreeSelectionChange)); + + subscription.unsubscribe(); + })); + + xit('should handle accessing `optionSelectionChanges` before the options are initialized', + fakeAsync(() => { + fixture.destroy(); + fixture = TestBed.createComponent(BasicTreeSelect); + + const spy = jasmine.createSpy('option selection spy'); + let subscription: Subscription; + + expect(fixture.componentInstance.select.options).toBeFalsy(); + expect(() => { + subscription = fixture.componentInstance.select.optionSelectionChanges.subscribe(spy); + }).not.toThrow(); + + fixture.detectChanges(); + trigger = fixture.debugElement.query(By.css('.mc-tree-select__trigger')).nativeElement; + + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + expect(spy).toHaveBeenCalledWith(jasmine.any(McTreeSelectionChange)); + + /* tslint:disable-next-line:no-unnecessary-type-assertion */ + subscription!.unsubscribe(); + })); + }); + + describe('forms integration', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicTreeSelect); + fixture.detectChanges(); + trigger = fixture.debugElement.query(By.css('.mc-tree-select__trigger')).nativeElement; + })); + + it('should take an initial view value with reactive forms', fakeAsync(() => { + fixture.componentInstance.control = new FormControl('rootNode_1'); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.mc-tree-select__matcher')); + expect(value.nativeElement.textContent) + .toContain('rootNode_1', `Expected trigger to be populated by the control's initial value.`); + + trigger = fixture.debugElement.query(By.css('.mc-tree-select__trigger')).nativeElement; + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = overlayContainerElement.querySelectorAll('mc-tree-option'); + expect(options[0].classList) + .toContain('mc-selected', + `Expected option with the control's initial value to be selected.`); + })); + + it('should set the view value from the form', fakeAsync(() => { + let value = fixture.debugElement.query(By.css('.mc-tree-select__matcher')); + expect(value.nativeElement.textContent.trim()).toBe('Food'); + + fixture.componentInstance.control.setValue('rootNode_1'); + fixture.detectChanges(); + + value = fixture.debugElement.query(By.css('.mc-tree-select__matcher')); + expect(value.nativeElement.textContent) + .toContain('rootNode_1', `Expected trigger to be populated by the control's new value.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = overlayContainerElement.querySelectorAll('mc-tree-option'); + expect(options[0].classList).toContain( + 'mc-selected', `Expected option with the control's new value to be selected.`); + })); + + it('should update the form value when the view changes', fakeAsync(() => { + expect(fixture.componentInstance.control.value) + .toEqual(null, `Expected the control's value to be empty initially.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.control.value) + .toEqual('rootNode_1', `Expected control's value to be set to the new option.`); + })); + + it('should clear the selection when a nonexistent option value is selected', fakeAsync(() => { + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + fixture.componentInstance.control.setValue('gibberish'); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.mc-tree-select__matcher')); + expect(value.nativeElement.textContent.trim()) + .toBe('Food', `Expected trigger to show the placeholder.`); + expect(trigger.textContent) + .not.toContain('Pizza', `Expected trigger is cleared when option value is not found.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = + overlayContainerElement.querySelectorAll('mc-tree-option'); + expect(options[1].classList) + .not.toContain('mc-selected', `Expected option w/ the old value not to be selected.`); + })); + + + it('should clear the selection when the control is reset', fakeAsync(() => { + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + fixture.componentInstance.control.reset(); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.mc-tree-select__matcher')); + expect(value.nativeElement.textContent.trim()) + .toBe('Food', `Expected trigger to show the placeholder.`); + expect(trigger.textContent) + .not.toContain('Pizza', `Expected trigger is cleared when option value is not found.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = + overlayContainerElement.querySelectorAll('mc-tree-option'); + expect(options[1].classList) + .not.toContain('mc-selected', `Expected option w/ the old value not to be selected.`); + })); + + it('should set the control to touched when the select is blurred', fakeAsync(() => { + expect(fixture.componentInstance.control.touched) + .toEqual(false, `Expected the control to start off as untouched.`); + + trigger.click(); + dispatchFakeEvent(trigger, 'blur'); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.control.touched) + .toEqual(false, `Expected the control to stay untouched when menu opened.`); + + const backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + backdrop.click(); + dispatchFakeEvent(trigger, 'blur'); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.control.touched) + .toEqual(true, `Expected the control to be touched as soon as focus left the select.`); + })); + + it('should set the control to touched when the panel is closed', fakeAsync(() => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + trigger.click(); + dispatchFakeEvent(trigger, 'blur'); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched when dropdown opened.'); + + fixture.componentInstance.select.close(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.control.touched) + .toBe(true, 'Expected the control to be touched when the panel was closed.'); + })); + + it('should not set touched when a disabled select is touched', fakeAsync(() => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + fixture.componentInstance.control.disable(); + dispatchFakeEvent(trigger, 'blur'); + + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched.'); + })); + + it('should set the control to dirty when the select value changes in DOM', fakeAsync(() => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mc-tree-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.control.dirty) + .toEqual(true, `Expected control to be dirty after value was changed by user.`); + })); + + it('should not set the control to dirty when the value changes programmatically', + fakeAsync(() => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + fixture.componentInstance.control.setValue('pizza-1'); + + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to stay pristine after programmatic change.`); + })); + + xit('should set an asterisk after the label if control is required', fakeAsync(() => { + let requiredMarker = fixture.debugElement.query(By.css('.mc-form-field-required-marker')); + expect(requiredMarker) + .toBeNull(`Expected label not to have an asterisk, as control was not required.`); + + fixture.componentInstance.isRequired = true; + fixture.detectChanges(); + + requiredMarker = fixture.debugElement.query(By.css('.mc-form-field-required-marker')); + expect(requiredMarker) + .not.toBeNull(`Expected label to have an asterisk, as control was required.`); + })); + }); + + describe('disabled behavior', () => { + it('should disable itself when control is disabled programmatically', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicTreeSelect); + fixture.detectChanges(); + + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + const trigger = fixture.debugElement.query(By.css('.mc-tree-select__trigger')).nativeElement; + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('default', `Expected cursor to be default arrow on disabled control.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent) + .toEqual('', `Expected select panel to stay closed.`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(false, `Expected select panelOpen property to stay false.`); + + fixture.componentInstance.control.enable(); + fixture.detectChanges(); + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('pointer', `Expected cursor to be a pointer on enabled control.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent) + .toContain('rootNode_1', `Expected select panel to open normally on re-enabled control`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(true, `Expected select panelOpen property to become true.`); + })); + }); + + xdescribe('keyboard scrolling', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + let panel: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicTreeSelect); + + fixture.detectChanges(); + fixture.componentInstance.select.open(); + fixture.detectChanges(); + flush(); + + host = fixture.debugElement.query(By.css('mc-tree-select')).nativeElement; + panel = overlayContainerElement.querySelector('.mc-tree-select__panel') as HTMLElement; + })); + + it('should not scroll to options that are completely in the view', fakeAsync(() => { + const initialScrollPosition = panel.scrollTop; + + [1, 2, 3].forEach(() => { + dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); + }); + + expect(panel.scrollTop) + .toBe(initialScrollPosition, 'Expected scroll position not to change'); + })); + + it('should scroll down to the active option', fakeAsync(() => { + for (let i = 0; i < 15; i++) { + dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); + } + + //