src/app/shared/components/tag-search/tag-search.component.ts
Component for searching, selecting, and adding tags.
OnDestroy
changeDetection | ChangeDetectionStrategy.OnPush |
selector | ccf-tag-search |
styleUrls | ./tag-search.component.scss |
templateUrl | ./tag-search.component.html |
Properties |
|
Methods |
Inputs |
Outputs |
HostBindings |
HostListeners |
constructor(el: ElementRef
|
||||||||||||||||
Creates an instance of tag search component.
Parameters :
|
placeholder | |
Type : string
|
|
Default value : 'Add Anatomical Structures ...'
|
|
Placeholder text |
search | |
Type : function
|
|
Search method |
searchLimit | |
Type : number
|
|
Maximum number of results to show |
searchThrottle | |
Type : number
|
|
Throttle time between search calls |
added | |
Type : EventEmitter
|
|
Emits when tags are added |
class |
Type : "ccf-tag-search"
|
Default value : 'ccf-tag-search'
|
HTML class name |
click |
click()
|
Opens the results panel |
focusin |
focusin()
|
Opens the results panel |
window:click | ||||||
Arguments : '$event'
|
||||||
window:click(event: Event)
|
||||||
Closes the results panel
Parameters :
|
window:focusin | ||||||
Arguments : '$event'
|
||||||
window:focusin(event: Event)
|
||||||
Closes the results panel
Parameters :
|
addTags |
addTags()
|
Emits selected tags and resets the search and selections
Returns :
void
|
closeResults | ||||||||
closeResults(event: Event)
|
||||||||
Decorators :
@HostListener('window:click', ['$event'])
|
||||||||
Closes the results panel
Parameters :
Returns :
void
|
hasCheckedTags |
hasCheckedTags()
|
Determines whether any tags have been checked
Returns :
boolean
true if any tag has been checked by the user |
openResults |
openResults()
|
Decorators :
@HostListener('click')
|
Opens the results panel
Returns :
void
|
tagId |
tagId(_index: number, tag: Tag)
|
Extracts the tag identifier
Returns :
TagId
The identifier corresponding to the tag |
checkedResults |
Type : Record<TagId | boolean>
|
Default value : {}
|
Object of currently checked search results |
closeSearch |
Type : ElementRef<HTMLElement>
|
Decorators :
@ViewChild('closeSearch', {read: ElementRef, static: false})
|
Element for close search button |
Readonly clsName |
Type : string
|
Default value : 'ccf-tag-search'
|
Decorators :
@HostBinding('class')
|
HTML class name |
Readonly countMapping |
Type : object
|
Default value : {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'=1': '1 result',
other: '# results',
}
|
Mapping for pluralizing the result total count |
resultsVisible |
Default value : false
|
Whether results are shown |
Readonly searchControl |
Default value : new UntypedFormControl()
|
Search field controller |
searchResults |
Default value : EMPTY_RESULT
|
Search results |
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostBinding,
HostListener,
Input,
OnDestroy,
Output,
ViewChild,
} from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { bind as Bind } from 'bind-decorator';
import { GoogleAnalyticsService } from 'ngx-google-analytics';
import { ObservableInput, Subject, from, interval } from 'rxjs';
import { catchError, map, switchMap, takeUntil, throttle } from 'rxjs/operators';
import { Tag, TagId, TagSearchResult } from '../../../core/models/anatomical-structure-tag';
/** Default search results limit */
const DEFAULT_SEARCH_LIMIT = 5;
/** Default search throttle time in ms */
const DEFAULT_SEARCH_THROTTLE = 100;
/** Empty search result object */
const EMPTY_RESULT: TagSearchResult = { totalCount: 0, results: [] };
/**
* Component for searching, selecting, and adding tags.
*/
@Component({
selector: 'ccf-tag-search',
templateUrl: './tag-search.component.html',
styleUrls: ['./tag-search.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TagSearchComponent implements OnDestroy {
/** HTML class name */
@HostBinding('class') readonly clsName = 'ccf-tag-search';
/** Placeholder text */
@Input() placeholder = 'Add Anatomical Structures ...';
/** Search method */
@Input() search?: (text: string, limit: number) => ObservableInput<TagSearchResult>;
/** Maximum number of results to show */
@Input() searchLimit?: number;
/** Throttle time between search calls */
@Input() searchThrottle?: number;
/** Emits when tags are added */
@Output() readonly added = new EventEmitter<Tag[]>();
/** Element for close search button */
@ViewChild('closeSearch', { read: ElementRef, static: false }) closeSearch!: ElementRef<HTMLElement>;
/** Mapping for pluralizing the result total count */
readonly countMapping = {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'=1': '1 result',
other: '# results',
};
/** Search field controller */
readonly searchControl = new UntypedFormControl();
/** Search results */
searchResults = EMPTY_RESULT;
/** Object of currently checked search results */
checkedResults: Record<TagId, boolean> = {};
/** Whether results are shown */
resultsVisible = false;
/** Emits and completes when component is destroyed. Used to clean up observables. */
private readonly destroy$ = new Subject<void>();
/**
* Creates an instance of tag search component.
*
* @param el Element for this component
* @param ga Analytics service
* @param cdr Reference to change detector
*/
constructor(
private readonly el: ElementRef<Node>,
private readonly ga: GoogleAnalyticsService,
cdr: ChangeDetectorRef,
) {
this.searchControl.valueChanges
.pipe(
takeUntil(this.destroy$),
throttle(() => interval(this.searchThrottle ?? DEFAULT_SEARCH_THROTTLE), { leading: true, trailing: true }),
switchMap(this.executeSearch),
)
.subscribe((result) => {
this.searchResults = result;
this.checkedResults = this.getUpdatedCheckedResults(result);
cdr.markForCheck();
});
}
/**
* Cleans up component on destruction
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Extracts the tag identifier
*
* @param _index Unused
* @param tag A tag
* @returns The identifier corresponding to the tag
*/
tagId(_index: number, tag: Tag): TagId {
return tag.id;
}
/**
* Determines whether any tags have been checked
*
* @returns true if any tag has been checked by the user
*/
hasCheckedTags(): boolean {
return Object.values(this.checkedResults).some((v) => v);
}
/**
* Emits selected tags and resets the search and selections
*/
addTags(): void {
const { searchControl, searchResults, checkedResults } = this;
const tags = searchResults.results.filter((tag) => checkedResults[tag.id]);
if (tags.length > 0) {
searchControl.reset();
this.searchResults = EMPTY_RESULT;
this.checkedResults = {};
this.ga.event('tags_added', 'tag_search', tags.map((tag) => tag.label).join(','));
this.added.emit(tags);
}
}
/**
* Opens the results panel
*/
@HostListener('click') // eslint-disable-line
@HostListener('focusin') // eslint-disable-line
openResults(): void {
if (!this.resultsVisible) {
this.resultsVisible = true;
}
}
/**
* Closes the results panel
*
* @param event DOM event
*/
@HostListener('window:click', ['$event']) // eslint-disable-line
@HostListener('window:focusin', ['$event']) // eslint-disable-line
closeResults(event: Event): void {
const { closeSearch } = this;
if (this.resultsVisible && event.target instanceof Node) {
if (!this.el.nativeElement.contains(event.target) || closeSearch.nativeElement.contains(event.target)) {
this.resultsVisible = false;
}
}
}
/**
* Executes a search on a piece of text.
*
* @param text Search text
* @returns An observable of the search result.
*/
@Bind
private executeSearch(text: string): ObservableInput<TagSearchResult> {
const { search, searchLimit = DEFAULT_SEARCH_LIMIT } = this;
if (!text || !search) {
return [EMPTY_RESULT];
}
return from(search(text, searchLimit)).pipe(
catchError(() => [EMPTY_RESULT]),
map(this.truncateResults),
);
}
/**
* Truncates the number of results returned by a search
*
* @param result The results
* @returns Results with at most `searchLimit` items
*/
@Bind
private truncateResults(result: TagSearchResult): TagSearchResult {
const { searchLimit = DEFAULT_SEARCH_LIMIT } = this;
const items = result.results;
if (items.length > searchLimit) {
return {
...result,
results: items.slice(0, searchLimit),
};
}
return result;
}
/**
* Computes a new checked object for result items. Already checked items are preserved.
*
* @param result New results
* @returns A new checked object
*/
private getUpdatedCheckedResults(result: TagSearchResult): Record<TagId, boolean> {
const prev = this.checkedResults;
return result.results.reduce<Record<TagId, boolean>>((acc, { id }) => {
acc[id] = prev[id] ?? false;
return acc;
}, {});
}
}
<div class="spacer"></div>
<mat-form-field class="overlay" [class.expanded]="resultsVisible" appearance="outline" subscriptSizing="dynamic">
<div class="search-box">
<input matInput type="search" [placeholder]="placeholder" [formControl]="searchControl" #search />
<button
class="add-button"
[class.active]="hasCheckedTags()"
[disabled]="!hasCheckedTags()"
(click)="addTags(); search.focus()"
matSuffix
#closeSearch
>
<mat-icon class="icon">add</mat-icon>
</button>
</div>
<div *ngIf="resultsVisible" class="results">
<div *ngFor="let result of searchResults.results; trackBy: tagId" class="item">
<mat-checkbox labelPosition="after" [(ngModel)]="checkedResults[result.id]">
{{ result.label }}
</mat-checkbox>
</div>
<div class="count">
{{ searchResults.totalCount | i18nPlural: countMapping }}
</div>
</div>
</mat-form-field>
./tag-search.component.scss
:host {
display: block;
position: relative;
.spacer {
// Calculated by adding up all padding/margin/height of material form fields
height: 3.5rem;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
.search-box {
display: flex;
width: 100%;
align-items: center;
height: 3rem;
.add-button {
border-radius: 0.25rem;
border: none;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
height: 100%;
}
}
.results {
margin-top: 0.5rem;
.count {
margin-top: 0.5rem;
font-size: 0.75rem;
text-align: end;
}
}
::ng-deep .mat-mdc-form-field-infix {
min-height: inherit;
padding: 0;
}
}
}