Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[WIP] feat: add demo for tree-view-type-ahead #39

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions projects/angular/src/clr-angular.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ClrPopoverModule } from './popover/popover.module';
import { ClrConditionalModule } from './utils/conditional/conditional.module';
import { ClrFocusOnViewInitModule } from './utils/focus/focus-on-view-init/focus-on-view-init.module';
import { ClrFocusTrapModule } from './utils/focus-trap/focus-trap.module';
import { ClrForTypeAheadModule } from './utils/for-type-ahead/for-type-ahead.module';
import { ClrLoadingModule } from './utils/loading/loading.module';
import { ClrWizardModule } from './wizard/wizard.module';
import { ClrStepperModule } from './accordion/stepper/stepper.module';
Expand All @@ -39,6 +40,7 @@ import '@cds/core/icon/register';
ClrConditionalModule,
ClrFocusTrapModule,
ClrFocusOnViewInitModule,
ClrForTypeAheadModule,
ClrButtonModule,
ClrFormsModule,
ClrLayoutModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export abstract class TreeNodeModel<T> {
expanded: boolean;
selected = new BehaviorSubject<ClrSelectedState>(ClrSelectedState.UNSELECTED);
model: T | null;
textContent: string;
/*
* Ideally, I would like to use a polymorphic this type here to ensure homogeneity of the tree, something like:
* abstract parent: this<T> | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,40 @@ export default function (): void {
treeFocusManager.focusNodeBelow(treeIdModelRefs.id6);
expect(focusRequestedOnId).toBeNull();
});

describe('.focusNodeStartsWith', () => {
treeIdModelRefs.id1.textContent = 'one';
treeIdModelRefs.id2.textContent = 'two';
treeIdModelRefs.id3.textContent = 'three';
treeIdModelRefs.id4.textContent = 'four';
treeIdModelRefs.id5.textContent = 'five';
treeIdModelRefs.id6.textContent = 'six';
treeIdModelRefs.id7.textContent = 'seven';
treeIdModelRefs.id8.textContent = 'eight';

it('finds and focuses node that starts with given string from children/sibling nodes', () => {
treeFocusManager.focusNodeStartsWith('eig', treeIdModelRefs.id1);
expect(focusRequestedOnId).toBe('id8');
treeFocusManager.focusNodeStartsWith('t', treeIdModelRefs.id1);
expect(focusRequestedOnId).toBe('id2');
treeFocusManager.focusNodeStartsWith('th', treeIdModelRefs.id1);
expect(focusRequestedOnId).toBe('id3');
treeFocusManager.focusNodeStartsWith('s', treeIdModelRefs.id8);
expect(focusRequestedOnId).toBe('id7');
});

it('finds and focuses node that starts with given string from root node if not found in children/sibling nodes', () => {
treeFocusManager.focusNodeStartsWith('f', treeIdModelRefs.id7);
expect(focusRequestedOnId).toBe('id4');
});

it('finds and focuses node that starts with given string by skipping nodes that are not expanded', () => {
treeFocusManager.focusNodeStartsWith('s', treeIdModelRefs.id2);
expect(focusRequestedOnId).toBe('id6');
treeIdModelRefs.id3.expanded = false;
treeFocusManager.focusNodeStartsWith('s', treeIdModelRefs.id2);
expect(focusRequestedOnId).toBe('id7');
});
});
});
}
69 changes: 69 additions & 0 deletions projects/angular/src/data/tree-view/tree-focus-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,71 @@ export class TreeFocusManagerService<T> {
}
}

private findNodeStartsWith(searchString: string, model: TreeNodeModel<T>): TreeNodeModel<T> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the return type should be TreeNodeModel<T> | null

if (!model) {
return null;
}

if (model.textContent.startsWith(searchString)) {
return model;
}

if (model.expanded && model.children.length > 0) {
for (const childModel of model.children) {
const found = this.findNodeStartsWith(searchString, childModel);
if (found) {
return found;
}
}
}

return null;
}

private findClosestNodeStartsWith(searchString: string, model: TreeNodeModel<T>): TreeNodeModel<T> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the return type should be TreeNodeModel<T> | null

if (!model) {
return null;
}

// Look from its own descendents first
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are lots of chunks of logic in these private functions that might be easier to unit test in isolation as utilities or helpers

if (model.expanded && model.children.length > 0) {
for (const childModel of model.children) {
const found = this.findNodeStartsWith(searchString, childModel);
if (found) {
return found;
}
}
}

const siblings = this.findSiblings(model);
const selfIndex = siblings.indexOf(model);

// Look from sibling nodes
for (let i = selfIndex + 1; i < siblings.length; i++) {
const siblingModel = siblings[i];
const found = this.findNodeStartsWith(searchString, siblingModel);
if (found) {
return found;
}
}

// Look from parent nodes
for (const rootModel of this.rootNodeModels) {
// Don't look from a parent yet
if (model.parent && model.parent === rootModel) {
continue;
}

const found = this.findNodeStartsWith(searchString, rootModel);
if (found) {
return found;
}
}

// Now look from a parent
return this.findNodeStartsWith(searchString, model.parent);
}

focusNode(model: TreeNodeModel<T>): void {
if (model) {
this._focusRequest.next(model.nodeId);
Expand Down Expand Up @@ -134,4 +199,8 @@ export class TreeFocusManagerService<T> {
focusNodeBelow(model: TreeNodeModel<T>): void {
this.focusNode(this.findNodeBelow(model));
}

focusNodeStartsWith(searchString: string, model: TreeNodeModel<T>): void {
this.focusNode(this.findClosestNodeStartsWith(searchString, model));
}
}
2 changes: 2 additions & 0 deletions projects/angular/src/data/tree-view/tree-node.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default function (): void {
'parent',
platformID,
undefined,
undefined,
this.featureService,
this.expandService,
stringsService,
Expand All @@ -84,6 +85,7 @@ export default function (): void {
'node',
platformID,
this.parent,
undefined,
this.featureService,
this.expandService,
stringsService,
Expand Down
45 changes: 39 additions & 6 deletions projects/angular/src/data/tree-view/tree-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { animate, style, transition, trigger, state } from '@angular/animations';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { isPlatformBrowser } from '@angular/common';
import {
AfterContentInit,
Component,
ContentChildren,
ElementRef,
Expand All @@ -20,18 +21,19 @@ import {
Output,
PLATFORM_ID,
QueryList,
Self,
SkipSelf,
ViewChild,
} from '@angular/core';
import { Subscription } from 'rxjs';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, filter } from 'rxjs/operators';

import { KeyCodes } from './../../utils/enums/key-codes.enum';
import { IfExpandService } from '../../utils/conditional/if-expanded.service';
import { keyValidator, preventArrowKeyScroll } from '../../utils/focus/key-focus/util';
import { isKeyALetter, keyValidator, preventArrowKeyScroll } from '../../utils/focus/key-focus/util';
import { ForTypeAheadProvider } from '../../utils/for-type-ahead/for-type-ahead.service';
import { ClrCommonStringsService } from '../../utils/i18n/common-strings.service';
import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-generator.service';
import { LoadingListener } from '../../utils/loading/loading-listener';
import { KeyCodes } from './../../utils/enums/key-codes.enum';
import { DeclarativeTreeNodeModel } from './models/declarative-tree-node.model';
import { ClrSelectedState } from './models/selected-state.enum';
import { TreeNodeModel } from './models/tree-node.model';
Expand All @@ -41,6 +43,10 @@ import { ClrTreeNodeLink } from './tree-node-link';

const LVIEW_CONTEXT_INDEX = 8;

// If the user types multiple keys without allowing 200ms to pass between them,
// then those keys are sent together in one request.
const TREE_TYPE_AHEAD_TIMEOUT = 200;

@Component({
selector: 'clr-tree-node',
templateUrl: './tree-node.html',
Expand All @@ -62,17 +68,22 @@ const LVIEW_CONTEXT_INDEX = 8;
'[class.clr-tree-node]': 'true',
},
})
export class ClrTreeNode<T> implements OnInit, OnDestroy {
export class ClrTreeNode<T> implements OnInit, AfterContentInit, OnDestroy {
STATES = ClrSelectedState;
private skipEmitChange = false;
isModelLoading = false;

private typeAheadKeyEvent: Subject<string> = new Subject<string>();

private typeAheadKeyBuffer = '';

constructor(
@Inject(UNIQUE_ID) public nodeId: string,
@Inject(PLATFORM_ID) private platformId: any,
@Optional()
@SkipSelf()
parent: ClrTreeNode<T>,
@Optional() @Self() private forTypeAheadProvider: ForTypeAheadProvider,
public featuresService: TreeFeaturesService<T>,
public expandService: IfExpandService,
public commonStrings: ClrCommonStringsService,
Expand Down Expand Up @@ -188,6 +199,20 @@ export class ClrTreeNode<T> implements OnInit, OnDestroy {
this.subscriptions.push(
this._model.loading$.pipe(debounceTime(0)).subscribe(isLoading => (this.isModelLoading = isLoading))
);

this.subscriptions.push(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check out the takeUntil/destroy pattern in this PR

this.typeAheadKeyEvent.pipe(debounceTime(TREE_TYPE_AHEAD_TIMEOUT)).subscribe((bufferedKeys: string) => {
this.focusManager.focusNodeStartsWith(bufferedKeys, this._model);
// reset once bufferedKeys are used
this.typeAheadKeyBuffer = '';
})
);
}

ngAfterContentInit() {
if (this.forTypeAheadProvider) {
this._model.textContent = this.forTypeAheadProvider.textContent;
}
}

ngOnDestroy() {
Expand Down Expand Up @@ -267,8 +292,16 @@ export class ClrTreeNode<T> implements OnInit, OnDestroy {
this.toggleExpandOrTriggerDefault();
break;
default:
if (this._model.textContent && isKeyALetter(event)) {
this.typeAheadKeyBuffer += event.key;
this.typeAheadKeyEvent.next(this.typeAheadKeyBuffer);
return;
}
break;
}

// if non-letter keys are pressed, do reset.
this.typeAheadKeyBuffer = '';
}

private get isParent() {
Expand Down
3 changes: 3 additions & 0 deletions projects/angular/src/popover/common/abstract-popover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ describe('Abstract Popover', function () {
// popover should stay open if button is clicked again
btn.dispatchEvent(new Event('click'));
expect(toggleService.open).toBe(true);

// must cleanup elements that are manually added to document body
document.body.removeChild(btn);
});
});
});
6 changes: 6 additions & 0 deletions projects/angular/src/utils/focus/key-focus/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ export function preventArrowKeyScroll(event: KeyboardEvent) {
event.preventDefault();
}
}

export function isKeyALetter(event: KeyboardEvent) {
const char = event.key;
// only letter characters differ when they switch between lowercase and uppercase.
return char.toLowerCase() !== char.toUpperCase() || (char >= '0' && char <= '9');
}
15 changes: 15 additions & 0 deletions projects/angular/src/utils/for-type-ahead/for-type-ahead.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ClrForTypeAhead } from './for-type-ahead';
@NgModule({
imports: [CommonModule],
declarations: [ClrForTypeAhead],
exports: [ClrForTypeAhead],
})
export class ClrForTypeAheadModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';

@Injectable()
export class ForTypeAheadProvider {
private _textContentChange: Subject<string> = new Subject<string>();

get textContentChange(): Observable<string> {
return this._textContentChange.asObservable();
}

private _textContent: string;

get textContent() {
return this._textContent;
}

set textContent(value: string) {
this._textContent = value;
this._textContentChange.next(value);
}
}
60 changes: 60 additions & 0 deletions projects/angular/src/utils/for-type-ahead/for-type-ahead.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ClrForTypeAhead } from './for-type-ahead';
import { ClrForTypeAheadModule } from './for-type-ahead.module';
import { ForTypeAheadProvider } from './for-type-ahead.service';
import { By } from '@angular/platform-browser';

@Component({
template: ` <span [clrForTypeAhead]="textContent">World</span> `,
})
class TestComponent {
textContent: string;
}

describe('ClrForTypeAhead', () => {
let fixture: ComponentFixture<any>;
let component: TestComponent;

let forTypeAheadDirectiveDE: DebugElement;
let forTypeAheadProvider: ForTypeAheadProvider;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [ClrForTypeAheadModule],
declarations: [TestComponent],
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;

forTypeAheadDirectiveDE = fixture.debugElement.query(By.directive(ClrForTypeAhead));
forTypeAheadProvider = forTypeAheadDirectiveDE.injector.get(ForTypeAheadProvider);
});

afterEach(() => {
fixture.destroy();
});

it('can use textContent of element if nothing gets passed to clrForTypeAhead input', () => {
fixture.detectChanges();
expect(forTypeAheadProvider.textContent).toBe('world');
});

it('can prioritize and use clrForTypeAhead input value', () => {
component.textContent = 'Hello';
fixture.detectChanges();
expect(forTypeAheadProvider.textContent).toBe('hello');
component.textContent = ' Hai ';
fixture.detectChanges();
expect(forTypeAheadProvider.textContent).toBe('hai');
component.textContent = '';
fixture.detectChanges();
expect(forTypeAheadProvider.textContent).toBe('world');
});
});
Loading