Skip to content

Commit a73681a

Browse files
authored
Merge pull request #267 from esuau/feat/search
feat(search): Add search component
2 parents e6051e7 + 685ccfe commit a73681a

File tree

6 files changed

+379
-0
lines changed

6 files changed

+379
-0
lines changed

src/i18n/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@
8383
"OVERFLOW_MENU": {
8484
"OVERFLOW": "Overflow"
8585
},
86+
"SEARCH": {
87+
"LABEL": "Search",
88+
"PLACEHOLDER": "Search",
89+
"CLEAR_BUTTON": "Clear search input"
90+
},
8691
"SIDENAV": {
8792
"NAV_LABEL": "Side navigation"
8893
},

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export * from "./pill-input/pill-input.module";
2222
export * from "./placeholder/placeholder.module";
2323
export * from "./progress-indicator/progress-indicator.module";
2424
export * from "./radio/radio.module";
25+
export * from "./search/search.module";
2526
export * from "./select/select.module";
2627
export * from "./switch/switch.module";
2728
export * from "./table/table.module";
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { ComponentFixture, TestBed } from "@angular/core/testing";
2+
import { By } from "@angular/platform-browser";
3+
4+
import { Search } from "./search.component";
5+
import { FormsModule } from "@angular/forms";
6+
import { I18nModule } from "../i18n/i18n.module";
7+
8+
describe("Search", () => {
9+
let component: Search;
10+
let fixture: ComponentFixture<Search>;
11+
let inputElement: HTMLInputElement;
12+
let containerElement: HTMLElement;
13+
let clearButtonElement: HTMLButtonElement;
14+
15+
beforeEach(() => {
16+
TestBed.configureTestingModule({
17+
declarations: [Search],
18+
imports: [FormsModule, I18nModule],
19+
providers: []
20+
});
21+
});
22+
23+
beforeEach(() => {
24+
fixture = TestBed.createComponent(Search);
25+
component = fixture.componentInstance;
26+
inputElement = fixture.debugElement.query(By.css("input")).nativeElement;
27+
});
28+
29+
it("should work", () => {
30+
expect(component instanceof Search).toBe(true);
31+
});
32+
33+
it("should bind input value", () => {
34+
component.value = "Text";
35+
fixture.detectChanges();
36+
expect(inputElement.value).toEqual("Text");
37+
});
38+
39+
it("should bind input disabled", () => {
40+
expect(inputElement.disabled).toEqual(false);
41+
component.disabled = true;
42+
fixture.detectChanges();
43+
expect(inputElement.disabled).toEqual(true);
44+
});
45+
46+
it("should bind input required", () => {
47+
component.required = true;
48+
fixture.detectChanges();
49+
expect(inputElement.required).toEqual(true);
50+
});
51+
52+
it("should display component of the correct size", () => {
53+
containerElement = fixture.debugElement.query(By.css(".bx--search")).nativeElement;
54+
component.size = "lg";
55+
fixture.detectChanges();
56+
expect(containerElement.className.includes("bx--search--lg")).toEqual(true);
57+
component.size = "sm";
58+
fixture.detectChanges();
59+
expect(containerElement.className.includes("bx--search--sm")).toEqual(true);
60+
});
61+
62+
it("should display clear button", () => {
63+
clearButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
64+
component.value = "";
65+
fixture.detectChanges();
66+
expect(clearButtonElement.className.includes("bx--search-close--hidden")).toEqual(true);
67+
component.value = "Text";
68+
fixture.detectChanges();
69+
expect(clearButtonElement.className.includes("bx--search-close--hidden")).toEqual(false);
70+
});
71+
72+
it("should clear input when clear button is clicked", () => {
73+
component.value = "Text";
74+
fixture.detectChanges();
75+
clearButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
76+
clearButtonElement.click();
77+
fixture.detectChanges();
78+
expect(component.value).toEqual("");
79+
});
80+
81+
it("should have dark and light theme", () => {
82+
containerElement = fixture.debugElement.query(By.css(".bx--search")).nativeElement;
83+
component.theme = "dark";
84+
fixture.detectChanges();
85+
expect(containerElement.className.includes("bx--search--light")).toEqual(false);
86+
component.theme = "light";
87+
fixture.detectChanges();
88+
expect(containerElement.className.includes("bx--search--light")).toEqual(true);
89+
});
90+
});

src/search/search.component.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import {
2+
Component,
3+
Input,
4+
EventEmitter,
5+
Output,
6+
HostBinding
7+
} from "@angular/core";
8+
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from "@angular/forms";
9+
import { I18n } from "../i18n/i18n.module";
10+
11+
/**
12+
* Used to emit changes performed on search components.
13+
* @export
14+
* @class SearchChange
15+
*/
16+
export class SearchChange {
17+
/**
18+
* Contains the `Search` that has been changed.
19+
* @type {Search}
20+
* @memberof SearchChange
21+
*/
22+
source: Search;
23+
/**
24+
* The value of the `Search` field encompassed in the `SearchChange` class.
25+
* @type {string}
26+
* @memberof SearchChange
27+
*/
28+
value: string;
29+
}
30+
31+
/**
32+
* @export
33+
* @class Search
34+
* @implements {ControlValueAccessor}
35+
*/
36+
@Component({
37+
selector: "ibm-search",
38+
template: `
39+
<div
40+
class="bx--search"
41+
[ngClass]="{
42+
'bx--search--sm': size === 'sm',
43+
'bx--search--lg': size === 'lg',
44+
'bx--search--light': theme === 'light'
45+
}"
46+
role="search">
47+
<label class="bx--label" [for]="id">{{label}}</label>
48+
<input
49+
class="bx--search-input"
50+
type="text"
51+
role="search"
52+
[id]="id"
53+
[value]="value"
54+
[placeholder]="placeholder"
55+
[disabled]="disabled"
56+
[required]="required"
57+
(input)="onSearch($event.target.value)"/>
58+
<svg
59+
class="bx--search-magnifier"
60+
width="16"
61+
height="16"
62+
viewBox="0 0 16 16">
63+
<path
64+
d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm4.936-1.27l4.563 4.557-.707.708-4.563-4.558a6.5 6.5 0 1 1 .707-.707z"
65+
fill-rule="nonzero"/>
66+
</svg>
67+
<button
68+
class="bx--search-close"
69+
[ngClass]="{
70+
'bx--search-close--hidden': !value || value.length === 0
71+
}"
72+
[title]="clearButtonTitle"
73+
[attr.aria-label]="clearButtonTitle"
74+
(click)="clearSearch()">
75+
<svg
76+
width="16"
77+
height="16"
78+
viewBox="0 0 16 16"
79+
xmlns="http://www.w3.org/2000/svg">
80+
<path
81+
d="M8 6.586L5.879 4.464 4.464 5.88 6.586 8l-2.122 2.121 1.415 1.415L8 9.414l2.121 2.122 1.415-1.415L9.414
82+
8l2.122-2.121-1.415-1.415L8 6.586zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"
83+
fill-rule="evenodd"/>
84+
</svg>
85+
</button>
86+
</div>
87+
`,
88+
providers: [
89+
{
90+
provide: NG_VALUE_ACCESSOR,
91+
useExisting: Search,
92+
multi: true
93+
}
94+
]
95+
})
96+
export class Search implements ControlValueAccessor {
97+
/**
98+
* Variable used for creating unique ids for search components.
99+
*/
100+
static searchCount = 0;
101+
102+
@HostBinding("class.bx--form-item") containerClass = true;
103+
104+
/**
105+
* `light` or `dark` search theme.
106+
*/
107+
@Input() theme: "light" | "dark" = "dark";
108+
/**
109+
* Size of the search field.
110+
*/
111+
@Input() size: "sm" | "lg" = "lg";
112+
/**
113+
* Set to `true` for a disabled search input.
114+
*/
115+
@Input() disabled = false;
116+
/**
117+
* Sets the name attribute on the `input` element.
118+
*/
119+
@Input() name: string;
120+
/**
121+
* The unique id for the search component.
122+
*/
123+
@Input() id = `search-${Search.searchCount}`;
124+
/**
125+
* Reflects the required attribute of the `input` element.
126+
*/
127+
@Input() required: boolean;
128+
/**
129+
* Sets the value attribute on the `input` element.
130+
*/
131+
@Input() value = "";
132+
/**
133+
* Sets the text inside the `label` tag.
134+
*/
135+
@Input() label = this.i18n.get().SEARCH.LABEL;
136+
/**
137+
* Sets the placeholder attribute on the `input` element.
138+
*/
139+
@Input() placeholder = this.i18n.get().SEARCH.PLACEHOLDER;
140+
/**
141+
* Used to set the `title` attribute of the clear button.
142+
*/
143+
@Input() clearButtonTitle = this.i18n.get().SEARCH.CLEAR_BUTTON;
144+
/**
145+
* Emits event notifying other classes when a change in state occurs in the input.
146+
*/
147+
@Output() change = new EventEmitter<SearchChange>();
148+
149+
/**
150+
* Creates an instance of `Search`.
151+
* @param i18n The i18n translations.
152+
* @memberof Search
153+
*/
154+
constructor(protected i18n: I18n) {
155+
Search.searchCount++;
156+
}
157+
158+
/**
159+
* This is the initial value set to the component
160+
* @param value The input value.
161+
*/
162+
public writeValue(value: any) {
163+
this.value = value;
164+
}
165+
166+
/**
167+
* Sets a method in order to propagate changes back to the form.
168+
* @param {any} fn
169+
* @memberof Search
170+
*/
171+
public registerOnChange(fn: any) {
172+
this.propagateChange = fn;
173+
}
174+
175+
/**
176+
* Registers a callback to be triggered when the control has been touched.
177+
* @param fn Callback to be triggered when the search input is touched.
178+
*/
179+
public registerOnTouched(fn: any) {
180+
this.onTouched = fn;
181+
}
182+
183+
/**
184+
* Called when search input is blurred. Needed to properly implement `ControlValueAccessor`.
185+
* @memberof Search
186+
*/
187+
onTouched: () => any = () => {};
188+
189+
/**
190+
* Method set in `registerOnChange` to propagate changes back to the form.
191+
* @memberof Search
192+
*/
193+
propagateChange = (_: any) => {};
194+
195+
/**
196+
* Called when text is written in the input.
197+
* @param {string} search The input text.
198+
*/
199+
onSearch(search: string) {
200+
this.value = search;
201+
this.emitChangeEvent();
202+
}
203+
204+
/**
205+
* Called when clear button is clicked.
206+
* @memberof Search
207+
*/
208+
clearSearch(): void {
209+
this.value = "";
210+
this.propagateChange(this.value);
211+
}
212+
213+
/**
214+
* Creates a class of `RadioChange` to emit the change in the `RadioGroup`.
215+
*/
216+
emitChangeEvent() {
217+
let event = new SearchChange();
218+
event.source = this;
219+
event.value = this.value;
220+
this.change.emit(event);
221+
this.propagateChange(this.value);
222+
}
223+
}

src/search/search.module.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// modules
2+
import { NgModule } from "@angular/core";
3+
import { FormsModule } from "@angular/forms";
4+
import { CommonModule } from "@angular/common";
5+
6+
// imports
7+
import { I18nModule } from "../i18n/i18n.module";
8+
import { Search } from "./search.component";
9+
10+
@NgModule({
11+
declarations: [
12+
Search
13+
],
14+
exports: [
15+
Search
16+
],
17+
imports: [
18+
FormsModule,
19+
CommonModule,
20+
I18nModule
21+
]
22+
})
23+
export class SearchModule { }
24+
25+
export { Search };

src/search/search.stories.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { storiesOf, moduleMetadata } from "@storybook/angular";
2+
import { withKnobs, boolean, select, text } from "@storybook/addon-knobs/angular";
3+
4+
import { SearchModule } from "../";
5+
6+
storiesOf("Search", module).addDecorator(
7+
moduleMetadata({
8+
imports: [SearchModule]
9+
})
10+
)
11+
.addDecorator(withKnobs)
12+
.add("Basic", () => ({
13+
template: `
14+
<div style="width: 250px;">
15+
<ibm-search [theme]="theme" [placeholder]="placeholder" [disabled]="disabled" size="lg"></ibm-search>
16+
</div>
17+
`,
18+
props: {
19+
theme: select("theme", ["dark", "light"], "dark"),
20+
disabled: boolean("disabled", false),
21+
placeholder: text("placeholder", "Search")
22+
}
23+
}))
24+
.add("Small", () => ({
25+
template: `
26+
<div style="width: 250px;">
27+
<ibm-search [theme]="theme" [placeholder]="placeholder" [disabled]="disabled" size="sm"></ibm-search>
28+
</div>
29+
`,
30+
props: {
31+
theme: select("theme", ["dark", "light"], "dark"),
32+
disabled: boolean("disabled", false),
33+
placeholder: text("placeholder", "Search")
34+
}
35+
}));

0 commit comments

Comments
 (0)