Skip to content
This repository was archived by the owner on Dec 18, 2024. It is now read-only.

Commit dfcf761

Browse files
committed
feat(navbar): Add basic searchbar component to site.
1 parent 1acc345 commit dfcf761

File tree

11 files changed

+327
-17
lines changed

11 files changed

+327
-17
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"highlight.js": "^9.9.0",
4141
"jasmine-core": "2.4.1",
4242
"jasmine-spec-reporter": "2.5.0",
43-
"karma": "1.2.0",
43+
"karma": "1.6.0",
4444
"karma-browserstack-launcher": "^1.2.0",
4545
"karma-chrome-launcher": "^2.0.0",
4646
"karma-jasmine": "^1.1.0",

src/app/shared/navbar/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './navbar';
2+
export * from './searchbar';

src/app/shared/navbar/navbar.html

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<a md-button class="docs-button" routerLink="components">Components</a>
1010
<a md-button class="docs-button" routerLink="guides">Guides</a>
1111
<div class="flex-spacer"></div>
12+
<search-bar-component></search-bar-component>
1213
<theme-picker></theme-picker>
1314
<a md-button class="docs-button" href="https://github.com/angular/material2" aria-label="GitHub Repository">
1415
<img class="docs-github-logo"

src/app/shared/navbar/navbar.scss

-11
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
11
.docs-navbar {
22
display: flex;
33
flex-wrap: wrap;
4-
align-items: center;
54
padding: 8px 16px;
6-
7-
> .mat-button {
8-
&:last-child {
9-
margin-left: auto;
10-
}
11-
}
12-
}
13-
14-
.flex-spacer {
15-
flex-grow: 1;
165
}
176

187
.docs-angular-logo {

src/app/shared/navbar/navbar.spec.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
2+
import {MdSnackBarModule} from '@angular/material';
23

34
import {NavBar, NavBarModule} from './navbar';
5+
import {DocumentationItems} from '../documentation-items/documentation-items';
46
import {DocsAppTestingModule} from '../../testing/testing-module';
57

68

@@ -10,7 +12,8 @@ describe('NavBar', () => {
1012

1113
beforeEach(async(() => {
1214
TestBed.configureTestingModule({
13-
imports: [NavBarModule, DocsAppTestingModule],
15+
imports: [NavBarModule, DocsAppTestingModule, MdSnackBarModule],
16+
providers: [DocumentationItems],
1417
}).compileComponents();
1518
}));
1619

src/app/shared/navbar/navbar.ts

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import {Component, NgModule} from '@angular/core';
2-
import {MdButtonModule} from '@angular/material';
2+
import {CommonModule} from '@angular/common';
3+
import {ReactiveFormsModule} from '@angular/forms';
4+
import {
5+
MdIconModule,
6+
MdButtonModule,
7+
MdOptionModule,
8+
MdAutocompleteModule
9+
} from '@angular/material';
310
import {RouterModule} from '@angular/router';
11+
412
import {ThemePickerModule} from '../theme-picker/theme-picker';
13+
import {SearchBar} from './searchbar/searchbar';
514

615
@Component({
716
selector: 'app-navbar',
@@ -11,8 +20,17 @@ import {ThemePickerModule} from '../theme-picker/theme-picker';
1120
export class NavBar {}
1221

1322
@NgModule({
14-
imports: [MdButtonModule, RouterModule, ThemePickerModule],
15-
exports: [NavBar],
16-
declarations: [NavBar],
23+
imports: [
24+
CommonModule,
25+
MdAutocompleteModule,
26+
MdButtonModule,
27+
MdOptionModule,
28+
RouterModule,
29+
ReactiveFormsModule,
30+
MdIconModule,
31+
ThemePickerModule
32+
],
33+
exports: [NavBar, SearchBar],
34+
declarations: [NavBar, SearchBar],
1735
})
1836
export class NavBarModule {}
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './searchbar';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<div class="docs-search-input-container">
2+
<input
3+
placeholder="Search"
4+
type="text"
5+
class="docs-search-input"
6+
(focus)="toggleIsExpanded()"
7+
(blur)="toggleIsExpanded($event.relatedTarget)"
8+
(keyup.enter)="handlePlainSearch($event.target.value.toLowerCase())"
9+
[mdAutocomplete]="auto"
10+
[formControl]="searchControl">
11+
<md-icon class="docs-search-icon">search</md-icon>
12+
</div>
13+
14+
<md-autocomplete #auto="mdAutocomplete" [displayWith]="displayFn">
15+
<md-option
16+
*ngFor="let item of filteredSuggestions | async"
17+
(click)="handlePlainSearch(item.name.toLowerCase())"
18+
[value]="item">
19+
{{item.name}}
20+
</md-option>
21+
</md-autocomplete>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
@mixin color-placeholder() {
2+
-webkit-font-smoothing: antialiased;
3+
color: white;
4+
}
5+
6+
:host {
7+
position: relative;
8+
flex: 2;
9+
10+
* {
11+
box-sizing: border-box;
12+
}
13+
14+
&.docs-expanded .docs-search-input-container {
15+
width: 100%;
16+
}
17+
18+
.docs-search-input-container {
19+
display: block;
20+
position: relative;
21+
margin-left: auto;
22+
height: 100%;
23+
width: 200px;
24+
transition: width .2s ease;
25+
26+
.docs-search-icon {
27+
position: absolute;
28+
left: 15px; top: 50%;
29+
transform: translateY(-50%);
30+
height: 28px;
31+
width: 28px;
32+
}
33+
}
34+
35+
.docs-search-input {
36+
background: rgba(255, 255, 255, 0.4);
37+
border: none;
38+
border-radius: 2px;
39+
color: white;
40+
font-size: 18px;
41+
height: 95%;
42+
line-height: 95%;
43+
padding-left: 50px;
44+
position: relative;
45+
transition: width .2s ease;
46+
width: 100%;
47+
48+
/* Set placeholder text to be white */
49+
&::-webkit-input-placeholder { @include color-placeholder(); } /* Chrome/Opera/Safari */
50+
&::-moz-placeholder { @include color-placeholder(); } /* Firefox 19+ */
51+
&:-moz-placeholder { @include color-placeholder(); } /* Firefox 18- */
52+
&:ms-input-placeholder { @include color-placeholder(); } /* IE 10+ */
53+
54+
&:focus {
55+
outline: none;
56+
}
57+
}
58+
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {Injectable} from '@angular/core';
2+
import {TestBed, inject, async, ComponentFixture} from '@angular/core/testing';
3+
import {Router, RouterModule} from '@angular/router';
4+
import {MaterialModule} from '@angular/material';
5+
import {ReactiveFormsModule, FormControl} from '@angular/forms';
6+
7+
import {DocumentationItems, DocItem} from '../../documentation-items/documentation-items';
8+
import {SearchBar} from './searchbar';
9+
10+
const mockRouter = {
11+
navigate: jasmine.createSpy('navigate'),
12+
navigateByUrl: jasmine.createSpy('navigateByUrl')
13+
};
14+
15+
const testDocItem = {
16+
id: 'test-doc-item',
17+
name: 'TestingExample',
18+
examples: ['test-examples']
19+
};
20+
21+
22+
class MockDocumentationItems extends DocumentationItems {
23+
getAllItems(): DocItem[] { return [testDocItem]; }
24+
}
25+
26+
27+
describe('SearchBar', () => {
28+
let fixture: ComponentFixture<SearchBar>;
29+
let component: SearchBar;
30+
31+
beforeEach(async(() => {
32+
TestBed.configureTestingModule({
33+
imports: [RouterModule, ReactiveFormsModule, MaterialModule],
34+
declarations: [SearchBar],
35+
providers: [
36+
{provide: DocumentationItems, useClass: MockDocumentationItems},
37+
{provide: Router, useValue: mockRouter},
38+
],
39+
});
40+
41+
TestBed.compileComponents();
42+
fixture = TestBed.createComponent(SearchBar);
43+
component = fixture.componentInstance;
44+
component.searchControl = new FormControl('');
45+
fixture.detectChanges();
46+
}));
47+
48+
afterEach(() => {
49+
(<any>component._router.navigateByUrl).calls.reset();
50+
});
51+
52+
it('should toggle isExpanded', () => {
53+
expect(component._isExpanded).toBe(false);
54+
component.toggleIsExpanded();
55+
expect(component._isExpanded).toBe(true);
56+
});
57+
58+
describe('Filter Search Suggestions', () => {
59+
it('should return all items matching search query', () => {
60+
const query = 'testing';
61+
const result = component.filterSearchSuggestions(query);
62+
expect(result).toEqual([testDocItem]);
63+
});
64+
65+
it('should return empty list if no items match', () => {
66+
const query = 'does not exist';
67+
const result = component.filterSearchSuggestions(query);
68+
expect(result).toEqual([]);
69+
});
70+
});
71+
72+
describe('Navigate', () => {
73+
74+
it('should take an id and navigate to the given route', () => {
75+
component._navigate('button-toggle');
76+
expect(component._router.navigateByUrl).toHaveBeenCalled();
77+
});
78+
79+
it('should not navigate if no id is given', () => {
80+
component._navigate('');
81+
expect(component._router.navigateByUrl).not.toHaveBeenCalled();
82+
});
83+
});
84+
85+
it('should show a snackbar error', () => {
86+
spyOn(component._snackBar, 'open');
87+
component._showError();
88+
expect(component._snackBar.open).toHaveBeenCalled();
89+
expect(component._snackBar.open).toHaveBeenCalledWith(
90+
'No search results found.',
91+
null, {duration: 3000});
92+
});
93+
94+
it('should return the proper display value for form control', () => {
95+
const result = component.displayFn(testDocItem);
96+
expect(result).toEqual(testDocItem.name);
97+
});
98+
});
99+
100+
101+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {Component, ViewChild} from '@angular/core';
2+
import {MdAutocompleteTrigger, MdSnackBar} from '@angular/material';
3+
import {Router} from '@angular/router';
4+
import {FormControl} from '@angular/forms';
5+
6+
import {Observable} from 'rxjs/Observable';
7+
import {Subscription} from 'rxjs/Subscription';
8+
import 'rxjs/add/operator/mergeMap';
9+
10+
import {DocumentationItems, DocItem} from '../../documentation-items/documentation-items';
11+
12+
13+
@Component({
14+
selector: 'search-bar-component',
15+
templateUrl: './searchbar.html',
16+
styleUrls: ['./searchbar.scss'],
17+
host: {
18+
'[class.docs-expanded]': '_isExpanded'
19+
}
20+
})
21+
22+
export class SearchBar {
23+
24+
@ViewChild(MdAutocompleteTrigger)
25+
private _autocompleteTrigger: MdAutocompleteTrigger;
26+
27+
allDocItems: DocItem[];
28+
filteredSuggestions: Observable<DocItem[]>;
29+
searchControl: FormControl = new FormControl('');
30+
subscription: Subscription;
31+
32+
_isExpanded: boolean = false;
33+
34+
constructor(
35+
public _docItems: DocumentationItems,
36+
public _router: Router,
37+
public _snackBar: MdSnackBar
38+
) {
39+
this.allDocItems = _docItems.getAllItems();
40+
this.filteredSuggestions = this.searchControl.valueChanges
41+
.startWith(null)
42+
.map(item => item ? this.filterSearchSuggestions(item) : this.allDocItems.slice());
43+
}
44+
45+
// This handles the user interacting with the autocomplete panel clicks or keyboard.
46+
ngAfterViewInit() {
47+
// We listen to the changes on `filteredSuggestions in order to
48+
// listen to the latest _autocompleteTrigger.optionSelections
49+
this.subscription = this.filteredSuggestions
50+
.flatMap(_ => this._autocompleteTrigger.optionSelections)
51+
.subscribe(evt => this._navigate(evt.source.value.id));
52+
}
53+
54+
ngOnDestroy() {
55+
if (this.subscription) { this.subscription.unsubscribe(); }
56+
}
57+
58+
toggleIsExpanded(evt?: any) {
59+
if (!this._isExpanded && evt === null || evt && evt.tagName === 'MD-OPTION') {
60+
// input not expanded and blurring || input is expanded and we clicked on an option
61+
return;
62+
} else if (this._isExpanded && evt === undefined) {
63+
// input is expanded and we are not blurring
64+
this._delayDropdown(false);
65+
} else {
66+
// defualt behaviour: not expanded and focusing || expanded and blurring
67+
this._delayDropdown(this._isExpanded);
68+
this._isExpanded = !this._isExpanded;
69+
}
70+
}
71+
72+
displayFn(item: DocItem) {
73+
return item.name;
74+
}
75+
76+
filterSearchSuggestions(searchTerm): DocItem[] {
77+
return this.allDocItems.filter(item => new RegExp(`^${searchTerm}`, 'gi').test(item.name));
78+
}
79+
80+
handlePlainSearch(searchTerm) {
81+
const item = this.allDocItems.find(item => item.name.toLowerCase() === searchTerm);
82+
return item ?
83+
this._navigate(item.id) :
84+
this.navigateToClosestMatch(searchTerm);
85+
}
86+
87+
navigateToClosestMatch(term) {
88+
const item = this.filterSearchSuggestions(term)[0];
89+
item ?
90+
this._navigate(item.id) :
91+
this._showError();
92+
}
93+
94+
_navigate(id) {
95+
this._resetSearch();
96+
return id ? this._router.navigateByUrl(`/components/component/${id}`) : null;
97+
}
98+
99+
_resetSearch() {
100+
this.searchControl.reset();
101+
this.searchControl.setValue('');
102+
}
103+
104+
_showError() {
105+
this._snackBar.open('No search results found.', null, {duration: 3000});
106+
}
107+
108+
_delayDropdown(isExpanded: boolean) {
109+
if (isExpanded) {
110+
this._autocompleteTrigger.closePanel();
111+
} else {
112+
this._autocompleteTrigger.closePanel();
113+
setTimeout(() => this._autocompleteTrigger.openPanel(), 210);
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)