Skip to content

Commit 2a6cd21

Browse files
authored
feat: support searching in multi select component (#625)
* feat: support searching in multi select component
1 parent 9e66d03 commit 2a6cd21

File tree

7 files changed

+119
-10
lines changed

7 files changed

+119
-10
lines changed

projects/components/src/multi-select/multi-select.component.scss

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
}
6565

6666
.multi-select-content {
67-
@include dropdown();
67+
@include dropdown(6px);
6868
min-width: 120px;
6969

7070
.multi-select-option {
@@ -91,4 +91,13 @@
9191
background: $gray-1;
9292
}
9393
}
94+
95+
.search-bar {
96+
display: flex;
97+
height: 34px;
98+
margin-top: 2px;
99+
cursor: pointer;
100+
font-size: 14px;
101+
align-items: center;
102+
}
94103
}

projects/components/src/multi-select/multi-select.component.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { fakeAsync, flush } from '@angular/core/testing';
22
import { IconType } from '@hypertrace/assets-library';
3+
import { SearchBoxComponent } from '@hypertrace/components';
34
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest';
45
import { MockComponent } from 'ng-mocks';
56
import { DividerComponent } from '../divider/divider.component';
@@ -14,7 +15,7 @@ describe('Multi Select Component', () => {
1415
component: MultiSelectComponent,
1516
imports: [LetAsyncModule],
1617
entryComponents: [SelectOptionComponent],
17-
declarations: [MockComponent(LabelComponent), MockComponent(DividerComponent)],
18+
declarations: [MockComponent(LabelComponent), MockComponent(DividerComponent), MockComponent(SearchBoxComponent)],
1819
shallow: true
1920
});
2021

@@ -250,4 +251,40 @@ describe('Multi Select Component', () => {
250251
expect(spectator.element).toHaveText(selectionOptions[1].label);
251252
expect(spectator.query('.trigger-content')!.getAttribute('style')).toBe('justify-content: flex-end;');
252253
}));
254+
255+
test('should show searchbox if applicable and function as expected', fakeAsync(() => {
256+
spectator = hostFactory(
257+
`
258+
<ht-multi-select [enableSearch]="enableSearch">
259+
<ht-select-option *ngFor="let option of options" [label]="option.label" [value]="option.value">
260+
</ht-select-option>
261+
</ht-multi-select>`,
262+
{
263+
hostProps: {
264+
options: selectionOptions,
265+
enableSearch: true
266+
}
267+
}
268+
);
269+
270+
spectator.tick();
271+
expect(spectator.query('.search-bar')).toExist();
272+
spectator.click('.search-bar');
273+
274+
spectator.triggerEventHandler(SearchBoxComponent, 'valueChange', 'fi');
275+
spectator.tick();
276+
277+
let options = spectator.queryAll('.multi-select-option', { root: true });
278+
expect(options.length).toBe(1);
279+
expect(options[0]).toContainText('first');
280+
281+
spectator.triggerEventHandler(SearchBoxComponent, 'valueChange', 'i');
282+
spectator.tick();
283+
284+
options = spectator.queryAll('.multi-select-option', { root: true });
285+
expect(options.length).toBe(2);
286+
expect(options[0]).toContainText('first');
287+
expect(options[1]).toContainText('third');
288+
flush();
289+
}));
253290
});

projects/components/src/multi-select/multi-select.component.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { LoggerService, queryListAndChanges$, TypedSimpleChanges } from '@hypert
1414
import { EMPTY, merge, Observable, of } from 'rxjs';
1515
import { map, switchMap } from 'rxjs/operators';
1616
import { IconSize } from '../icon/icon-size';
17+
import { SearchBoxDisplayMode } from '../search-box/search-box.component';
1718
import { SelectOption } from '../select/select-option';
1819
import { SelectOptionComponent } from '../select/select-option.component';
1920
import { SelectSize } from '../select/select-size';
@@ -56,16 +57,23 @@ import { MultiSelectJustify } from './multi-select-justify';
5657
</ht-popover-trigger>
5758
<ht-popover-content>
5859
<div class="multi-select-content" [ngStyle]="{ 'min-width.px': triggerContainer.offsetWidth }">
60+
<ng-container *ngIf="this.enableSearch">
61+
<ht-search-box
62+
class="search-bar"
63+
(valueChange)="this.searchOptions($event)"
64+
displayMode="${SearchBoxDisplayMode.NoBorder}"
65+
></ht-search-box>
66+
</ng-container>
5967
<ng-container *ngIf="this.showAllOptionControl">
6068
<div class="multi-select-option all-options" (click)="this.onAllSelectionChange()">
6169
<input class="checkbox" type="checkbox" [checked]="this.areAllOptionsSelected()" />
6270
<span class="label">Select All</span>
6371
</div>
64-
65-
<ht-divider></ht-divider>
6672
</ng-container>
6773
68-
<div *ngFor="let item of items" (click)="this.onSelectionChange(item)" class="multi-select-option">
74+
<ht-divider *ngIf="this.showAllOptionControl || this.enableSearch"></ht-divider>
75+
76+
<div *ngFor="let item of filteredItems" (click)="this.onSelectionChange(item)" class="multi-select-option">
6977
<input class="checkbox" type="checkbox" [checked]="this.isSelectedItem(item)" />
7078
<ht-icon
7179
class="icon"
@@ -104,6 +112,9 @@ export class MultiSelectComponent<V> implements AfterContentInit, OnChanges {
104112
@Input()
105113
public showBorder: boolean = false;
106114

115+
@Input()
116+
public enableSearch: boolean = false;
117+
107118
@Input()
108119
public justify: MultiSelectJustify = MultiSelectJustify.Left;
109120

@@ -122,12 +133,14 @@ export class MultiSelectComponent<V> implements AfterContentInit, OnChanges {
122133
public popoverOpen: boolean = false;
123134
public selected$?: Observable<SelectOption<V>[]>;
124135
public triggerLabel?: string;
136+
public filteredItems?: SelectOptionComponent<V>[];
125137

126138
public constructor(private readonly loggerService: LoggerService) {}
127139

128140
public ngAfterContentInit(): void {
129141
this.selected$ = this.buildObservableOfSelected();
130142
this.setTriggerLabel();
143+
this.filteredItems = this.items?.toArray();
131144
}
132145

133146
public ngOnChanges(changes: TypedSimpleChanges<this>): void {
@@ -137,6 +150,10 @@ export class MultiSelectComponent<V> implements AfterContentInit, OnChanges {
137150
this.setTriggerLabel();
138151
}
139152

153+
public searchOptions(searchText: string): void {
154+
this.filteredItems = this.items?.filter(item => item.label.toLowerCase().includes(searchText.toLowerCase()));
155+
}
156+
140157
public onAllSelectionChange(): void {
141158
this.selected = this.areAllOptionsSelected() ? [] : this.items!.map(item => item.value); // Select All or none
142159
this.setSelection();

projects/components/src/multi-select/multi-select.module.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,20 @@ import { IconModule } from '../icon/icon.module';
66
import { LabelModule } from '../label/label.module';
77
import { LetAsyncModule } from '../let-async/let-async.module';
88
import { PopoverModule } from '../popover/popover.module';
9+
import { TraceSearchBoxModule } from '../search-box/search-box.module';
910
import { MultiSelectComponent } from './multi-select.component';
1011

1112
@NgModule({
12-
imports: [FormsModule, CommonModule, IconModule, LabelModule, LetAsyncModule, PopoverModule, DividerModule],
13+
imports: [
14+
FormsModule,
15+
CommonModule,
16+
IconModule,
17+
LabelModule,
18+
LetAsyncModule,
19+
PopoverModule,
20+
DividerModule,
21+
TraceSearchBoxModule
22+
],
1323
declarations: [MultiSelectComponent],
1424
exports: [MultiSelectComponent]
1525
})

projects/components/src/search-box/search-box.component.scss

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
display: flex;
77
position: relative;
88
align-items: center;
9-
border: 1px solid $color-border;
10-
border-radius: 6px;
119
padding: 0 8px;
1210

1311
.icon {
@@ -25,6 +23,7 @@
2523
&.focused {
2624
caret-color: $blue-5;
2725
border: 1px solid $blue-5;
26+
border-radius: 6px;
2827
box-shadow: 0 1px 3px rgba(0, 83, 215, 0.16);
2928

3029
&:hover {
@@ -57,3 +56,14 @@
5756
}
5857
}
5958
}
59+
60+
.border {
61+
border: 1px solid $color-border;
62+
border-radius: 6px;
63+
}
64+
65+
.no-border {
66+
&:hover {
67+
border: none;
68+
}
69+
}

projects/components/src/search-box/search-box.component.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fakeAsync } from '@angular/core/testing';
22
import { runFakeRxjs } from '@hypertrace/test-utils';
33
import { createHostFactory, Spectator } from '@ngneat/spectator/jest';
4-
import { SearchBoxComponent } from './search-box.component';
4+
import { SearchBoxComponent, SearchBoxDisplayMode } from './search-box.component';
55

66
describe('Search box Component', () => {
77
let spectator: Spectator<SearchBoxComponent>;
@@ -32,6 +32,24 @@ describe('Search box Component', () => {
3232
});
3333
}));
3434

35+
test('should apply no-border class correctly', () => {
36+
spectator = createHost(`<ht-search-box [displayMode]="displayMode"></ht-search-box>`, {
37+
hostProps: {
38+
displayMode: SearchBoxDisplayMode.NoBorder
39+
}
40+
});
41+
42+
expect(spectator.query('.ht-search-box')?.classList).toContain('no-border');
43+
expect(spectator.query('.ht-search-box')?.classList).not.toContain('border');
44+
});
45+
46+
test('should apply border class correctly by default', () => {
47+
spectator = createHost(`<ht-search-box></ht-search-box>`);
48+
49+
expect(spectator.query('.ht-search-box')?.classList).not.toContain('no-border');
50+
expect(spectator.query('.ht-search-box')?.classList).toContain('border');
51+
});
52+
3553
test('should work with arbitrary debounce time', fakeAsync(() => {
3654
spectator = createHost(
3755
`<ht-search-box [placeholder]="placeholder" [debounceTime]="debounceTime"></ht-search-box>`,

projects/components/src/search-box/search-box.component.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { IconSize } from '../icon/icon-size';
1111
changeDetection: ChangeDetectionStrategy.OnPush,
1212
providers: [SubscriptionLifecycle],
1313
template: `
14-
<div class="ht-search-box" [class.focused]="this.isFocused">
14+
<div class="ht-search-box" [ngClass]="this.displayMode" [class.focused]="this.isFocused">
1515
<ht-icon icon="${IconType.Search}" size="${IconSize.Small}" class="icon" (click)="onSubmit()"></ht-icon>
1616
<input
1717
class="input"
@@ -43,6 +43,9 @@ export class SearchBoxComponent implements OnInit, OnChanges {
4343
@Input()
4444
public debounceTime?: number;
4545

46+
@Input()
47+
public displayMode: SearchBoxDisplayMode = SearchBoxDisplayMode.Border;
48+
4649
@Output()
4750
public readonly valueChange: EventEmitter<string> = new EventEmitter();
4851

@@ -91,3 +94,8 @@ export class SearchBoxComponent implements OnInit, OnChanges {
9194
);
9295
}
9396
}
97+
98+
export const enum SearchBoxDisplayMode {
99+
Border = 'border',
100+
NoBorder = 'no-border'
101+
}

0 commit comments

Comments
 (0)