From 7ffa889d8fe2191d1b60218d76f13b8ed1974fe7 Mon Sep 17 00:00:00 2001 From: Raven Avalon Date: Fri, 17 Mar 2017 14:24:09 -0400 Subject: [PATCH] feat(navbar): Add basic searchbar component to site. --- package.json | 2 +- src/app/shared/navbar/index.ts | 1 + src/app/shared/navbar/navbar.html | 1 + src/app/shared/navbar/navbar.scss | 11 -- src/app/shared/navbar/searchbar/index.ts | 1 + .../shared/navbar/searchbar/searchbar.html | 18 +++ .../shared/navbar/searchbar/searchbar.scss | 59 ++++++++++ .../shared/navbar/searchbar/searchbar.spec.ts | 99 ++++++++++++++++ src/app/shared/navbar/searchbar/searchbar.ts | 107 ++++++++++++++++++ src/app/shared/shared-module.ts | 7 +- 10 files changed, 292 insertions(+), 14 deletions(-) create mode 100644 src/app/shared/navbar/searchbar/index.ts create mode 100644 src/app/shared/navbar/searchbar/searchbar.html create mode 100644 src/app/shared/navbar/searchbar/searchbar.scss create mode 100644 src/app/shared/navbar/searchbar/searchbar.spec.ts create mode 100644 src/app/shared/navbar/searchbar/searchbar.ts diff --git a/package.json b/package.json index cef12aa9b..d5de12007 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 c8458a7e3..c82f5f518 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/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..eacf8f14e --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.html @@ -0,0 +1,18 @@ +
+ + 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..353aa2f34 --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.spec.ts @@ -0,0 +1,99 @@ +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'] +}; + + +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); + }); +}); + + +class MockDocumentationItems extends DocumentationItems { + getAllItems(): DocItem[] { return [testDocItem]; } +} + diff --git a/src/app/shared/navbar/searchbar/searchbar.ts b/src/app/shared/navbar/searchbar/searchbar.ts new file mode 100644 index 000000000..c9f3d66b6 --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.ts @@ -0,0 +1,107 @@ +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() { + if (this._autocompleteTrigger) { + 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) { + this._resetSearch(); + this._navigate(this.filterSearchSuggestions(term)[0].id); + } + + _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); + } + } +} diff --git a/src/app/shared/shared-module.ts b/src/app/shared/shared-module.ts index 7298839ca..ceb5f5930 100644 --- a/src/app/shared/shared-module.ts +++ b/src/app/shared/shared-module.ts @@ -1,12 +1,14 @@ import {NgModule} from '@angular/core'; import {HttpModule} from '@angular/http'; +import {ReactiveFormsModule} from '@angular/forms'; import {MaterialModule} from '@angular/material'; import {BrowserModule} from '@angular/platform-browser'; import {RouterModule} from '@angular/router'; -import {ExampleViewer} from './example-viewer/example-viewer'; import {DocViewer} from './doc-viewer/doc-viewer'; import {DocumentationItems} from './documentation-items/documentation-items'; +import {SearchBar} from './navbar/searchbar/searchbar'; +import {ExampleViewer} from './example-viewer/example-viewer'; import {PlunkerButton} from './plunker'; import {GuideItems} from './guide-items/guide-items'; import {ThemeStorage} from './theme-chooser/theme-storage/theme-storage'; @@ -21,9 +23,10 @@ import {SvgViewer} from './svg-viewer/svg-viewer'; HttpModule, RouterModule, BrowserModule, + ReactiveFormsModule, MaterialModule, ], - declarations: [DocViewer, ExampleViewer, NavBar, PlunkerButton, ThemeChooser, SvgViewer], + declarations: [DocViewer, ExampleViewer, NavBar, PlunkerButton, SearchBar, ThemeChooser, SvgViewer], exports: [DocViewer, ExampleViewer, NavBar, PlunkerButton, ThemeChooser, SvgViewer], providers: [DocumentationItems, GuideItems, ThemeStorage, SvgBuilder], entryComponents: [