diff --git a/package.json b/package.json index b790a922c..af364c1f3 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "highlight.js": "^9.9.0", "jasmine-core": "2.4.1", "jasmine-spec-reporter": "2.5.0", - "karma": "1.2.0", + "karma": "1.6.0", "karma-browserstack-launcher": "^1.2.0", "karma-chrome-launcher": "^2.0.0", "karma-jasmine": "^1.1.0", diff --git a/src/app/shared/navbar/index.ts b/src/app/shared/navbar/index.ts index f5899d036..a44923a77 100644 --- a/src/app/shared/navbar/index.ts +++ b/src/app/shared/navbar/index.ts @@ -1 +1,2 @@ export * from './navbar'; +export * from './searchbar'; diff --git a/src/app/shared/navbar/navbar.html b/src/app/shared/navbar/navbar.html index 53c5adf4f..4783af059 100644 --- a/src/app/shared/navbar/navbar.html +++ b/src/app/shared/navbar/navbar.html @@ -9,6 +9,7 @@ Components Guides
+ .mat-button { - &:last-child { - margin-left: auto; - } - } -} - -.flex-spacer { - flex-grow: 1; } .docs-angular-logo { diff --git a/src/app/shared/navbar/navbar.spec.ts b/src/app/shared/navbar/navbar.spec.ts index be5fd6604..b5da77f76 100644 --- a/src/app/shared/navbar/navbar.spec.ts +++ b/src/app/shared/navbar/navbar.spec.ts @@ -1,6 +1,8 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MdSnackBarModule} from '@angular/material'; import {NavBar, NavBarModule} from './navbar'; +import {DocumentationItems} from '../documentation-items/documentation-items'; import {DocsAppTestingModule} from '../../testing/testing-module'; @@ -10,7 +12,8 @@ describe('NavBar', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [NavBarModule, DocsAppTestingModule], + imports: [NavBarModule, DocsAppTestingModule, MdSnackBarModule], + providers: [DocumentationItems], }).compileComponents(); })); diff --git a/src/app/shared/navbar/navbar.ts b/src/app/shared/navbar/navbar.ts index 82d0dcab0..e598607ab 100644 --- a/src/app/shared/navbar/navbar.ts +++ b/src/app/shared/navbar/navbar.ts @@ -1,7 +1,16 @@ import {Component, NgModule} from '@angular/core'; -import {MdButtonModule} from '@angular/material'; +import {CommonModule} from '@angular/common'; +import {ReactiveFormsModule} from '@angular/forms'; +import { + MdIconModule, + MdButtonModule, + MdOptionModule, + MdAutocompleteModule +} from '@angular/material'; import {RouterModule} from '@angular/router'; + import {ThemePickerModule} from '../theme-picker/theme-picker'; +import {SearchBar} from './searchbar/searchbar'; @Component({ selector: 'app-navbar', @@ -11,8 +20,17 @@ import {ThemePickerModule} from '../theme-picker/theme-picker'; export class NavBar {} @NgModule({ - imports: [MdButtonModule, RouterModule, ThemePickerModule], - exports: [NavBar], - declarations: [NavBar], + imports: [ + CommonModule, + MdAutocompleteModule, + MdButtonModule, + MdOptionModule, + RouterModule, + ReactiveFormsModule, + MdIconModule, + ThemePickerModule + ], + exports: [NavBar, SearchBar], + declarations: [NavBar, SearchBar], }) export class NavBarModule {} diff --git a/src/app/shared/navbar/searchbar/index.ts b/src/app/shared/navbar/searchbar/index.ts new file mode 100644 index 000000000..2520d4d6f --- /dev/null +++ b/src/app/shared/navbar/searchbar/index.ts @@ -0,0 +1 @@ +export * from './searchbar'; diff --git a/src/app/shared/navbar/searchbar/searchbar.html b/src/app/shared/navbar/searchbar/searchbar.html new file mode 100644 index 000000000..26a207bca --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.html @@ -0,0 +1,21 @@ +
+ + search +
+ + + + {{item.name}} + + diff --git a/src/app/shared/navbar/searchbar/searchbar.scss b/src/app/shared/navbar/searchbar/searchbar.scss new file mode 100644 index 000000000..0aac39d9f --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.scss @@ -0,0 +1,59 @@ +@mixin color-placeholder() { + -webkit-font-smoothing: antialiased; + color: white; +} + +:host { + position: relative; + flex: 2; + + * { + box-sizing: border-box; + } + + &.docs-expanded .docs-search-input-container { + width: 100%; + } + + .docs-search-input-container { + display: block; + position: relative; + margin-left: auto; + height: 100%; + width: 200px; + transition: width .2s ease; + + .docs-search-icon { + position: absolute; + left: 15px; top: 50%; + transform: translateY(-50%); + height: 28px; + width: 28px; + } + } + + .docs-search-input { + background: rgba(255, 255, 255, 0.4); + border: none; + border-radius: 2px; + color: white; + font-size: 18px; + height: 95%; + line-height: 95%; + padding-left: 50px; + position: relative; + transition: width .2s ease; + width: 100%; + + /* Set placeholder text to be white */ + &::-webkit-input-placeholder { @include color-placeholder(); } /* Chrome/Opera/Safari */ + &::-moz-placeholder { @include color-placeholder(); } /* Firefox 19+ */ + &:-moz-placeholder { @include color-placeholder(); } /* Firefox 18- */ + &:ms-input-placeholder { @include color-placeholder(); } /* IE 10+ */ + + &:focus { + outline: none; + } + } + +} diff --git a/src/app/shared/navbar/searchbar/searchbar.spec.ts b/src/app/shared/navbar/searchbar/searchbar.spec.ts new file mode 100644 index 000000000..21036e508 --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.spec.ts @@ -0,0 +1,101 @@ +import {Injectable} from '@angular/core'; +import {TestBed, inject, async, ComponentFixture} from '@angular/core/testing'; +import {Router, RouterModule} from '@angular/router'; +import {MaterialModule} from '@angular/material'; +import {ReactiveFormsModule, FormControl} from '@angular/forms'; + +import {DocumentationItems, DocItem} from '../../documentation-items/documentation-items'; +import {SearchBar} from './searchbar'; + +const mockRouter = { + navigate: jasmine.createSpy('navigate'), + navigateByUrl: jasmine.createSpy('navigateByUrl') +}; + +const testDocItem = { + id: 'test-doc-item', + name: 'TestingExample', + examples: ['test-examples'] +}; + + +class MockDocumentationItems extends DocumentationItems { + getAllItems(): DocItem[] { return [testDocItem]; } +} + + +describe('SearchBar', () => { + let fixture: ComponentFixture; + let component: SearchBar; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [RouterModule, ReactiveFormsModule, MaterialModule], + declarations: [SearchBar], + providers: [ + {provide: DocumentationItems, useClass: MockDocumentationItems}, + {provide: Router, useValue: mockRouter}, + ], + }); + + TestBed.compileComponents(); + fixture = TestBed.createComponent(SearchBar); + component = fixture.componentInstance; + component.searchControl = new FormControl(''); + fixture.detectChanges(); + })); + + afterEach(() => { + (component._router.navigateByUrl).calls.reset(); + }); + + it('should toggle isExpanded', () => { + expect(component._isExpanded).toBe(false); + component.toggleIsExpanded(); + expect(component._isExpanded).toBe(true); + }); + + describe('Filter Search Suggestions', () => { + it('should return all items matching search query', () => { + const query = 'testing'; + const result = component.filterSearchSuggestions(query); + expect(result).toEqual([testDocItem]); + }); + + it('should return empty list if no items match', () => { + const query = 'does not exist'; + const result = component.filterSearchSuggestions(query); + expect(result).toEqual([]); + }); + }); + + describe('Navigate', () => { + + it('should take an id and navigate to the given route', () => { + component._navigate('button-toggle'); + expect(component._router.navigateByUrl).toHaveBeenCalled(); + }); + + it('should not navigate if no id is given', () => { + component._navigate(''); + expect(component._router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + + it('should show a snackbar error', () => { + spyOn(component._snackBar, 'open'); + component._showError(); + expect(component._snackBar.open).toHaveBeenCalled(); + expect(component._snackBar.open).toHaveBeenCalledWith( + 'No search results found.', + null, {duration: 3000}); + }); + + it('should return the proper display value for form control', () => { + const result = component.displayFn(testDocItem); + expect(result).toEqual(testDocItem.name); + }); +}); + + + diff --git a/src/app/shared/navbar/searchbar/searchbar.ts b/src/app/shared/navbar/searchbar/searchbar.ts new file mode 100644 index 000000000..9ed581084 --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.ts @@ -0,0 +1,116 @@ +import {Component, ViewChild} from '@angular/core'; +import {MdAutocompleteTrigger, MdSnackBar} from '@angular/material'; +import {Router} from '@angular/router'; +import {FormControl} from '@angular/forms'; + +import {Observable} from 'rxjs/Observable'; +import {Subscription} from 'rxjs/Subscription'; +import 'rxjs/add/operator/mergeMap'; + +import {DocumentationItems, DocItem} from '../../documentation-items/documentation-items'; + + +@Component({ + selector: 'search-bar-component', + templateUrl: './searchbar.html', + styleUrls: ['./searchbar.scss'], + host: { + '[class.docs-expanded]': '_isExpanded' + } +}) + +export class SearchBar { + + @ViewChild(MdAutocompleteTrigger) + private _autocompleteTrigger: MdAutocompleteTrigger; + + allDocItems: DocItem[]; + filteredSuggestions: Observable; + searchControl: FormControl = new FormControl(''); + subscription: Subscription; + + _isExpanded: boolean = false; + + constructor( + public _docItems: DocumentationItems, + public _router: Router, + public _snackBar: MdSnackBar + ) { + this.allDocItems = _docItems.getAllItems(); + this.filteredSuggestions = this.searchControl.valueChanges + .startWith(null) + .map(item => item ? this.filterSearchSuggestions(item) : this.allDocItems.slice()); + } + + // This handles the user interacting with the autocomplete panel clicks or keyboard. + ngAfterViewInit() { + // We listen to the changes on `filteredSuggestions in order to + // listen to the latest _autocompleteTrigger.optionSelections + this.subscription = this.filteredSuggestions + .flatMap(_ => this._autocompleteTrigger.optionSelections) + .subscribe(evt => this._navigate(evt.source.value.id)); + } + + ngOnDestroy() { + if (this.subscription) { this.subscription.unsubscribe(); } + } + + toggleIsExpanded(evt?: any) { + if (!this._isExpanded && evt === null || evt && evt.tagName === 'MD-OPTION') { + // input not expanded and blurring || input is expanded and we clicked on an option + return; + } else if (this._isExpanded && evt === undefined) { + // input is expanded and we are not blurring + this._delayDropdown(false); + } else { + // defualt behaviour: not expanded and focusing || expanded and blurring + this._delayDropdown(this._isExpanded); + this._isExpanded = !this._isExpanded; + } + } + + displayFn(item: DocItem) { + return item.name; + } + + filterSearchSuggestions(searchTerm): DocItem[] { + return this.allDocItems.filter(item => new RegExp(`^${searchTerm}`, 'gi').test(item.name)); + } + + handlePlainSearch(searchTerm) { + const item = this.allDocItems.find(item => item.name.toLowerCase() === searchTerm); + return item ? + this._navigate(item.id) : + this.navigateToClosestMatch(searchTerm); + } + + navigateToClosestMatch(term) { + const item = this.filterSearchSuggestions(term)[0]; + item ? + this._navigate(item.id) : + this._showError(); + } + + _navigate(id) { + this._resetSearch(); + return id ? this._router.navigateByUrl(`/components/component/${id}`) : null; + } + + _resetSearch() { + this.searchControl.reset(); + this.searchControl.setValue(''); + } + + _showError() { + this._snackBar.open('No search results found.', null, {duration: 3000}); + } + + _delayDropdown(isExpanded: boolean) { + if (isExpanded) { + this._autocompleteTrigger.closePanel(); + } else { + this._autocompleteTrigger.closePanel(); + setTimeout(() => this._autocompleteTrigger.openPanel(), 210); + } + } +}