| نمط المكون المخصص | التحدي المعماري في التصميم | الحل الهندسي باستخدام CVA |
|---|---|---|
| منتقي الأزرار المتعددة (`Multi-Select Toggle`) | مزامنة حالة الاختيار مع كائن النموذج الخارجي دون تداخل | تخزين المصفوفة الداخلية واستدعاء `onChange` مع كل تحديث للأزرار |
| حقل الإدخال المقنع (`Masked Input`) | تنسيق النصوص المدخلة (مثل أرقام الهواتف أو البطاقات) قبل وصولها للنموذج | تنقية وتنسيق القيمة محلياً في `writeValue` وإرسال القيمة الصافية عبر `onChange` |
| منتقي التاريخ المخصص (`Custom Date Picker`) | إدارة حالة فتح وإغلاق التقويم البصري مع تتبع الحقول | ربط دالة `onTouched` بحدث مغادرة العنصر لضمان تفعيل حالات التدقيق بدقة |
يُعد تصميم مكونات نماذج مخصصة وقابلة لإعادة الاستخدام (ControlValueAccessor) قمة الاحترافية في هندسة الواجهات. يتيح لك ربط ControlValueAccessor (أو اختصاراً CVA) دمج أي مكون مخصص (مثل منتقي تواريخ، أو قائمة منسدلة معقدة، أو حقل إدخال بتصميم فريد) مباشرة مع نظام النماذج في أنگولر (Template-Driven أو Reactive Forms) ليتم التعامل معه كأنه حقل إدخال عادي تماماً (FormControl).
يعمل ControlValueAccessor كحلقة وصل برمجية بين نموذج الأب (Parent Form) والمكون الفرعي (Custom Control)، حيث ينقل القيم من الأب إلى الابن (writeValue) وينقل التحديثات والتفاعل من الابن إلى الأب (onChange و onTouched).
عبر توفير NG_VALUE_ACCESSOR في مصفوفة providers الخاصة بالمكون، يتعرف أنگولر على المكون المخصص ويتعامل معه كحقل نموذج حقيقي يدعم التعطيل (setDisabledState) والتحقق والتدقيق دون أي تعقيد.
| 1 | import { Component, forwardRef } from '@angular/core'; |
| 2 | import { CommonModule } from '@angular/common'; |
| 3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; |
| 4 | |
| 5 | @Component({ |
| 6 | selector: 'app-custom-counter-input', |
| 7 | standalone: true, |
| 8 | imports: [CommonModule], |
| 9 | providers: [ |
| 10 | { |
| 11 | provide: NG_VALUE_ACCESSOR, |
| 12 | useExisting: forwardRef(() => CustomCounterInputComponent), |
| 13 | multi: true |
| 14 | } |
| 15 | ], |
| 16 | template: ` |
| 17 | <div class="custom-cva-card"> |
| 18 | <button type="button" (click)="decrement()" [disabled]="isDisabled" class="cva-btn">-</button> |
| 19 | <span class="cva-value-display">{{ value }}</span> |
| 20 | <button type="button" (click)="increment()" [disabled]="isDisabled" class="cva-btn">+</button> |
| 21 | </div> |
| 22 | `, |
| 23 | styles: [` |
| 24 | .custom-cva-card { display: flex; align-items: center; gap: 12px; background: #1e293b; padding: 8px 16px; border-radius: 8px; border: 1px solid #334155; width: fit-content; } |
| 25 | .cva-btn { background: #38bdf8; color: #090d16; border: none; width: 32px; height: 32px; border-radius: 6px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; } |
| 26 | .cva-btn:disabled { background: #475569; cursor: not-allowed; } |
| 27 | .cva-value-display { color: #fff; font-weight: bold; font-size: 16px; min-width: 24px; text-align: center; } |
| 28 | `] |
| 29 | }) |
| 30 | export class CustomCounterInputComponent implements ControlValueAccessor { |
| 31 | value = 0; |
| 32 | isDisabled = false; |
| 33 | |
| 34 | private onChange: (val: number) => void = () => {}; |
| 35 | private onTouched: () => void = () => {}; |
| 36 | |
| 37 | writeValue(value: any): void { |
| 38 | if (value !== undefined) { |
| 39 | this.value = value; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | registerOnChange(fn: any): void { |
| 44 | this.onChange = fn; |
| 45 | } |
| 46 | |
| 47 | registerOnTouched(fn: any): void { |
| 48 | this.onTouched = fn; |
| 49 | } |
| 50 | |
| 51 | setDisabledState(isDisabled: boolean): void { |
| 52 | this.isDisabled = isDisabled; |
| 53 | } |
| 54 | |
| 55 | increment() { |
| 56 | if (!this.isDisabled) { |
| 57 | this.value++; |
| 58 | this.onChange(this.value); |
| 59 | this.onTouched(); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | decrement() { |
| 64 | if (!this.isDisabled) { |
| 65 | this.value--; |
| 66 | this.onChange(this.value); |
| 67 | this.onTouched(); |
| 68 | } |
| 69 | } |
| 70 | } |
| 1 | import { Component } from '@angular/core'; |
| 2 | import { CommonModule } from '@angular/common'; |
| 3 | import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms'; |
| 4 | import { CustomCounterInputComponent } from './custom-counter-input.component'; |
| 5 | |
| 6 | @Component({ |
| 7 | selector: 'app-parent-form-host', |
| 8 | standalone: true, |
| 9 | imports: [CommonModule, ReactiveFormsModule, CustomCounterInputComponent], |
| 10 | template: ` |
| 11 | <div class="host-form-card"> |
| 12 | <h3>استخدام المكون المخصص CVA داخل نموذج تفاعلي</h3> |
| 13 | |
| 14 | <form [formGroup]="mainForm" (ngSubmit)="onSubmit()"> |
| 15 | <div class="field-block"> |
| 16 | <label>الكمية المطلوبة (عبر مكون مخصص):</label> |
| 17 | <!-- ربط المكون المخصص مباشرة بـ formControlName --> |
| 18 | <app-custom-counter-input formControlName="quantity"></app-custom-counter-input> |
| 19 | </div> |
| 20 | |
| 21 | <button type="submit" class="submit-action-btn">إرسال بيانات المكون المخصص</button> |
| 22 | </form> |
| 23 | |
| 24 | <div class="output-preview"> |
| 25 | <p>قيمة النموذج البرمجية: <strong>{{ mainForm.value | json }}</strong></p> |
| 26 | </div> |
| 27 | </div> |
| 28 | `, |
| 29 | styles: [` |
| 30 | .host-form-card { background: #090d16; color: #fff; padding: 24px; border-radius: 12px; border: 1px solid #1e293b; } |
| 31 | .field-block { margin-bottom: 20px; display: flex; flex-direction: column; gap: 8px; } |
| 32 | .submit-action-btn { background: #10b981; color: #090d16; border: none; padding: 10px 20px; border-radius: 6px; font-weight: bold; cursor: pointer; width: 100%; } |
| 33 | .output-preview { background: #1e293b; padding: 14px; border-radius: 8px; margin-top: 16px; border: 1px solid #334155; } |
| 34 | .output-preview p { margin: 0; color: #38bdf8; font-size: 13px; } |
| 35 | `] |
| 36 | }) |
| 37 | export class ParentFormHostComponent { |
| 38 | mainForm: FormGroup; |
| 39 | |
| 40 | constructor(private fb: FormBuilder) { |
| 41 | this.mainForm = this.fb.group({ |
| 42 | quantity: [3] // قيمة ابتدائية يتم نقلها للمكون المخصص تلقائياً |
| 43 | }); |
| 44 | } |
| 45 | |
| 46 | onSubmit() { |
| 47 | console.log('البيانات المُرسلة من النموذج الحاضن:', this.mainForm.value); |
| 48 | } |
| 49 | } |