מיד נראה איך לבנות רכיב אנגולר בסיסי שיתפקד כ-select או DDL והוא יעבוד כרכיב ב-reactive form וכרכיב ב-template form.
בנוסף, נבנה אותו כך שהוא יהיה נגיש לשימוש במקלדת ובקורא מסך.
תחילה אני רוצה להבהיר שניתן לממש את הרכיב במספר צורות, אבל יש מספר דברים שחייבים להיות ברכיב בלי קשר לצורת המימוש שלו.
נראה איך הוא בנוי ולאחר מכן נעבור על הדברים החשובים
הרכיב:
<div class="app-select" #selectWrapper [ngClass]="{'select-active':selectActive==true}"> <input type="text" #inputSelect role="combobox" [name]="selectName" class="select-main-input shadow-outline" [id]="dataId" readonly placeholder="Choose an option" [attr.aria-expanded]="selectActive" aria-haspopup="listbox" (click)="openListFnc()" (keydown)="inputKeyControlFnc($event)" [value]="inputValue" [attr.data-option-value]="inputOptionValue" (blur)="blurFnc()"> <img src="assets/icons/Arrow_Right.svg" alt="arrow right" aria-hidden="true" class="img-arrow-indicator"> <div class="select-list-hide"> <ul role="listbox" #optionslist class="select-list" tabindex="-1" (keydown)="listKeyControlFnc($event)" (blur)="blurFnc()" [attr.aria-activedescendant]="optionId"> <li #itemOption role="option" *ngFor="let dataItem of selectData; trackBy:trackByFnc;let optionIdx=index" [attr.data-value]="dataItem.itemValue" [attr.data-text]="dataItem.itemText" [id]="dataId + generateId(dataItem)" class="select-item" [class.active]="optionIdx===optionCnt" [attr.aria-selected]="optionIdx===optionCnt" (click)="optionClickFnc(optionIdx)"> <span>{{dataItem.itemText}}</span></li> </ul> </div> </div>
.app-select { position: relative; } .app-select .select-main-input { padding: 0; border: 1px solid rgb(190, 190, 190); background-color: transparent; min-width: 100px; min-height: 30px; width: 100%; position: relative; overflow: hidden; padding-right: 34px; text-align: left; padding-left: 10px; border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; -ms-border-radius: 4px; -o-border-radius: 4px; cursor: pointer; box-sizing: border-box; } .app-select .img-arrow-indicator { position: absolute; top: 0; bottom: 0; right: 0; height: 100%; transform: rotate(0deg); -webkit-transform: rotate(0deg); -moz-transform: rotate(0deg); -ms-transform: rotate(0deg); -o-transform: rotate(0deg); transition: all 0.5s; -webkit-transition: all 0.5s; -moz-transition: all 0.5s; -ms-transition: all 0.5s; -o-transition: all 0.5s; width: 28px; height: 28px; } .app-select.select-active .img-arrow-indicator { transform: rotate(90deg); -webkit-transform: rotate(90deg); -moz-transform: rotate(90deg); -ms-transform: rotate(90deg); -o-transform: rotate(90deg); } .app-select .select-list { list-style-type: none; padding: 0; margin: 0; width: 100%; border: 1px solid #818181; position: absolute; z-index: 999999; top: 100%; left: 0; max-height: 168px; overflow-y: auto; box-sizing: border-box; } .app-select .select-list .select-item { padding: 6px 15px 6px 25px; width: 100%; display: block; border: 0; border-bottom: 1px solid #b8b8b8; text-align: left; background-color: #ededed; word-break: break-all; cursor: pointer; box-sizing: border-box; } .app-select .select-list-hide { display: none; } .app-select.select-active .select-list-hide { display: block; } .app-select .select-list .select-item:hover { background-color: #f1f1f1; } .app-select .select-item.active { font-weight: bold; position: relative; } .app-select .select-item.active::before { content: url(../../../../assets/icons/check.svg); position: absolute; left: 5px; top: 7px; bottom: 6px; min-height: 15px; min-width: 15px; }
import { Component, OnInit, Input, forwardRef, ViewChild, ElementRef, ViewChildren, QueryList } from '@angular/core'; import { ISelect } from './select.interface'; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; @Component({ selector: 'app-select', templateUrl: './select.component.html', styleUrls: ['./select.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SelectComponent), multi: true } ] }) export class SelectComponent implements OnInit, ControlValueAccessor { @Input() dataId: string = ''; @Input() selectName: string = ''; @Input() selectData: ISelect[] = []; selectActive: boolean = false; @ViewChild('optionslist', { static: false }) optionslistRef: ElementRef; @ViewChild('inputSelect', { static: false }) inputSelectRef: ElementRef; @ViewChild('selectWrapper', { static: false }) selectWrapperRef: ElementRef; @ViewChildren('itemOption') itemOptionRef: QueryList<ElementRef>; optionCnt: number = null; inputValue: string = ''; inputOptionValue: string = ''; optionId: string = ''; validateFn: any = () => { }; constructor() { } ngOnInit() { } trackByFnc(index, item) { if (!item) return null; if (item.id) { return item.id; } if (item.itemValue) { return item.itemValue; } return index; } generateId(item: ISelect): string { if (item.id === undefined || item.id === "") { return item.itemValue; } else return item.id; } writeValue(value: any) { if (value !== undefined && value !== null && value !== '') { value = value.toString(); let prefixItem = this.selectData.findIndex(i => i.itemValue === value); this.optionClickFnc(prefixItem); } } propagateChange = (_: any) => { }; registerOnChange(fn) { this.propagateChange = fn; } registerOnTouched() { } openListFnc() { this.selectActive = true; setTimeout(() => { this.optionslistRef.nativeElement.focus() }, 100); } inputKeyControlFnc(e) { if (e.which === 40 || e.which === 13) { e.preventDefault(); this.selectActive = true; setTimeout(() => { this.optionslistRef.nativeElement.focus() }, 100); if (this.optionCnt === null) { this.optionCnt = 0; } this.updateInputFnc(); } } listKeyControlFnc(e) { if (e.which === 40) { e.preventDefault(); if (this.optionCnt < this.selectData.length - 1) { this.optionCnt++; } } if (e.which === 27 || e.which === 13) { this.selectActive = false; this.inputSelectRef.nativeElement.focus(); } if (e.which === 38) { e.preventDefault(); if (this.optionCnt > 0) { this.optionCnt--; } } this.updateInputFnc(); } updateInputFnc() { setTimeout(() => { let optionData = this.itemOptionRef.find(i => i.nativeElement.classList.contains('active')); optionData.nativeElement.parentElement.scrollTop = optionData.nativeElement.offsetTop - 90; this.inputValue = optionData.nativeElement.attributes["data-text"].value; this.inputOptionValue = optionData.nativeElement.attributes["data-value"].value; this.optionId = optionData.nativeElement.id; }, 50); } optionClickFnc(idx) { this.optionCnt = idx; this.updateInputFnc(); this.selectActive = false; if (this.inputSelectRef !== undefined) { this.inputSelectRef.nativeElement.focus(); } } blurFnc() { setTimeout(() => { if (this.selectWrapperRef.nativeElement.contains(document.activeElement) === false) { this.selectActive = false; this.propagateChange(this.inputOptionValue); } }, 50); } }
export interface ISelect { id?:string, itemText:string, itemValue:string }
הדברים שחובה לשים בכל רכיב שהולך לתקשר עם טופס של אנגולר הם:
- ControlValueAccessor
- NG_VALUE_ACCESSOR
- writeValue(value: any)
- propagateChange = (_: any)
- registerOnChange(fn)
- registerOnTouched()
כאשר אנחנו שמים רכיב בטופס אנגולר וזה לא משנה אם הוא טמפלייט או ריאקטיב אנחנו צריכים שהרכיב יתקשר עם הטופס,
כל הרשימה למעלה נועדה לתת לרכיב שלנו לתקשר עם הטופס.
נתחיל בזה שברגע שתממשו את ControlValueAccessor הקומפיילר לא יפסיק לעלות שגיאות עד שתממשו את כל הפונקציות שרשומות למעלה (למעט NG_VALUE_ACCESSOR), גם אם הפונקציות ריקות ולא עושות כלום.
ControlValueAccessor הוא הגשר בין ה-DOM (HTML) לבין ה-API של אנגולר.
writeValue היא פונקציה שתפקידה לקבל נתון מבחוץ ולהעביר אותו למשתנה בתוך הרכיב שלנו, הנתון מבחוץ לא מגיע כ-INPUT , אלא הוא מגיע מה-API של אנגולר.
במקרה של טופס טמפלייט הוא מגיע מה-ngModel, במקרה של ריאקטיב הוא מגיע מה-FormBuilder במידה ויש שם ערך.
propagateChange תפקידו לעדכן את העולם החיצון מהרכיב שמשהו השתנה בו.
registerOnChange פונקציה שתרשום את העדכון של propagateChange .
registerOnTouched פונקציה שמופעלת כאשר הרכיב מאבד פוקוס, ניתן להוסיף לוגיקה בתוך הפונקציה שתרוץ כל פעם שהמשתמש סימן את הרכיב ועבר לרכיב אחר.
NG_VALUE_ACCESSOR התפקיד שלו פשוט מאוד, כל מה שהוא עושה זה לרשום את הרכיב שלנו כ-form control וכך ה-form יכיר וידע איך לתקשר עם הרכיב שלנו.
כל שאר הפונקציות והמשתנים הם מימוש שלי לרכיב וניתן לשנות אותן או לממש אותן אחרת.
אני כן רוצה להתעכב ולהסביר פונקציה אחת ומה היא עושה:
blurFnc
זו פונקציה שרצה כל פעם שאחת האופציות של הבחירה מאבדת פוקוס, מה שהפונקציה עושה זה לבדוק מי נמצא כרגע בפוקוס, לקחת אותו ולבדוק אם הוא קיים בתוך הרכיב שלנו.
אם הוא לא קיים, משמע לחצנו מחוץ לרכיב, אז האופציות נסגרות.
איך מתקשרים עם הרכיב שלנו:
reactive-form
export class ReactiveFormPageComponent implements OnInit { simpleSelect: ISelect[] = simpleSelectData; // SOME DATA form:FormGroup; constructor(private fb:FormBuilder) { } ngOnInit() { this.form = this.fb.group({ select:['3',Validators.required], select1:'', }); } }
<form [formGroup]="form"> <div class="area-wrapper"> <app-select formControlName="select" [selectData]="simpleSelect" dataId="select1"></app-select> <app-select formControlName="select1" [selectData]="simpleSelect" dataId="select2"></app-select> </div> </form> <p>required: {{form.valid}}</p> <pre>{{ form.value | json }}</pre>
template form
export class TemplateFormPageComponent implements OnInit { simpleSelect: ISelect[] = simpleSelectData; // SOME DATA constructor() { } ngOnInit() { } }
<form #form="ngForm"> <div class="area-wrapper"> <app-select name="select" [ngModel]="5" [selectData]="simpleSelect" required dataId="select1"></app-select> <app-select name="select1" ngModel [selectData]="simpleSelect" required dataId="select2"></app-select> </div> </form> <p>required: {{form.valid}}</p> <pre>{{ form.value | json }}</pre>
בהצלחה 🐊