20. Template-driven Forms
📝 Template-Driven Forms в Angular
Заголовок раздела «📝 Template-Driven Forms в Angular»Template-driven формы используют директивы в шаблоне для управления формой. Логика живёт в HTML, а не в TypeScript. Отличный выбор для простых форм с несложной валидацией.
🔧 Подключение FormsModule
Заголовок раздела «🔧 Подключение FormsModule»// standalone компонентimport { Component } from '@angular/core';import { FormsModule } from '@angular/forms';
@Component({ standalone: true, imports: [FormsModule], template: `...`})export class RegistrationComponent {}Или в NgModule:
import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms';
@NgModule({ imports: [FormsModule], declarations: [RegistrationComponent]})export class AppModule {}🎯 NgForm и шаблонная переменная
Заголовок раздела «🎯 NgForm и шаблонная переменная»NgForm автоматически добавляется к каждому <form>. Получить доступ к ней можно через шаблонную переменную:
<form #f="ngForm" (ngSubmit)="onSubmit(f)"> <!-- поля формы --> <button type="submit" [disabled]="!f.valid">Отправить</button></form>Свойства NgForm:
f.valid // true — все поля валидныf.invalid // true — есть ошибки валидацииf.dirty // true — пользователь изменил хотя бы одно полеf.pristine // true — форма не тронутаf.touched // true — фокус был хотя бы на одном полеf.untouched // true — ни одно поле не получало фокусf.value // объект со всеми значениями: { email: '...', name: '...' }📌 NgModel — привязка поля
Заголовок раздела «📌 NgModel — привязка поля»[(ngModel)] создаёт двустороннюю привязку. name атрибут обязателен — именно он становится ключом в f.value:
<form #f="ngForm" (ngSubmit)="onSubmit(f)"> <input type="text" name="username" [(ngModel)]="user.username" required minlength="3" #username="ngModel" />
<div *ngIf="username.invalid && username.touched"> <span *ngIf="username.errors?.['required']">Имя обязательно</span> <span *ngIf="username.errors?.['minlength']"> Минимум {{ username.errors?.['minlength'].requiredLength }} символов </span> </div></form>#username="ngModel" — шаблонная переменная для доступа к состоянию конкретного поля.
🏗️ Полная форма регистрации
Заголовок раздела «🏗️ Полная форма регистрации»import { Component } from '@angular/core';import { FormsModule, NgForm } from '@angular/forms';import { CommonModule } from '@angular/common';
interface User { username: string; email: string; password: string; age: number | null; agreeToTerms: boolean;}
@Component({ standalone: true, imports: [FormsModule, CommonModule], template: ` <form #f="ngForm" (ngSubmit)="onSubmit(f)">
<!-- Имя пользователя --> <div class="field"> <label>Имя пользователя</label> <input type="text" name="username" [(ngModel)]="user.username" required minlength="3" maxlength="20" #username="ngModel" [class.error]="username.invalid && username.touched" /> <div class="errors" *ngIf="username.invalid && username.touched"> <span *ngIf="username.errors?.['required']">⚠️ Обязательное поле</span> <span *ngIf="username.errors?.['minlength']">⚠️ Минимум 3 символа</span> <span *ngIf="username.errors?.['maxlength']">⚠️ Максимум 20 символов</span> </div> </div>
<!-- Email --> <div class="field"> <label>Email</label> <input type="email" name="email" [(ngModel)]="user.email" required email #emailField="ngModel" /> <div class="errors" *ngIf="emailField.invalid && emailField.touched"> <span *ngIf="emailField.errors?.['required']">⚠️ Укажите email</span> <span *ngIf="emailField.errors?.['email']">⚠️ Неверный формат email</span> </div> </div>
<!-- Кнопка --> <button type="submit" [disabled]="f.invalid"> Зарегистрироваться </button>
<!-- Отладка --> <pre>{{ f.value | json }}</pre>
</form> `})export class RegistrationComponent { user: User = { username: '', email: '', password: '', age: null, agreeToTerms: false };
onSubmit(form: NgForm) { if (form.valid) { console.log('Отправляем:', form.value); } }}🗂️ ngModelGroup — группировка полей
Заголовок раздела «🗂️ ngModelGroup — группировка полей»ngModelGroup позволяет группировать поля в объект внутри f.value:
<form #f="ngForm">
<div ngModelGroup="personalInfo" #personalInfo="ngModelGroup"> <input name="firstName" [(ngModel)]="data.firstName" required /> <input name="lastName" [(ngModel)]="data.lastName" required /> </div>
<div ngModelGroup="address" #address="ngModelGroup"> <input name="city" [(ngModel)]="data.city" required /> <input name="street" [(ngModel)]="data.street" required /> </div>
</form>Результат f.value:
{ "personalInfo": { "firstName": "Яша", "lastName": "Смирнов" }, "address": { "city": "Москва", "street": "Ленина, 5" }}🔄 Сброс формы
Заголовок раздела «🔄 Сброс формы»// Полный сброс — значения и состоянияonReset(form: NgForm) { form.reset();}
// Сброс с начальными значениямиonReset(form: NgForm) { form.resetForm({ username: '', email: '', agreeToTerms: false });}<button type="button" (click)="onReset(f)">Очистить форму</button>После reset() все поля станут pristine и untouched.
🎨 CSS-классы от Angular
Заголовок раздела «🎨 CSS-классы от Angular»Angular автоматически добавляет CSS-классы к элементам формы:
| Класс | Когда добавляется |
|---|---|
ng-valid | Поле прошло валидацию |
ng-invalid | Поле не прошло валидацию |
ng-pristine | Поле не изменялось |
ng-dirty | Значение изменено |
ng-untouched | Фокуса не было |
ng-touched | Фокус был и убран |
input.ng-invalid.ng-touched { border-color: red; background: #fff0f0;}
input.ng-valid.ng-dirty { border-color: green;}⚡ Одностороннее связывание
Заголовок раздела «⚡ Одностороннее связывание»Не всегда нужна двусторонняя привязка [()]. Для простых случаев:
<!-- Только из модели в шаблон --><input type="text" name="email" [ngModel]="user.email" />
<!-- Только из шаблона в модель --><input type="text" name="email" (ngModelChange)="user.email = $event" />
<!-- Двустороннее = оба вместе --><input type="text" name="email" [(ngModel)]="user.email" />📊 Template-Driven vs Reactive Forms
Заголовок раздела «📊 Template-Driven vs Reactive Forms»| Критерий | Template-Driven | Reactive |
|---|---|---|
| Сложность | Простая | Выше |
| Тестируемость | Сложнее | Легко |
| Динамические поля | Сложно | Просто |
| Валидация | В шаблоне | В TypeScript |
| Реактивность | Через NgModel | Через Observables |
| Подходит для | Простых форм | Сложных форм |
export default function TemplateDrivenFormsPlayground() { const [form, setForm] = React.useState({ username: '', email: '', password: '', age: '', agreeToTerms: false }); const [touched, setTouched] = React.useState({}); const [submitted, setSubmitted] = React.useState(false);
const touch = (field) => setTouched(prev => ({ ...prev, [field]: true })); const change = (field, value) => setForm(prev => ({ ...prev, [field]: value }));
const validators = { username: (v) => { if (!v) return 'Обязательное поле'; if (v.length < 3) return `Минимум 3 символа (сейчас ${v.length})`; if (v.length > 20) return 'Максимум 20 символов'; return null; }, email: (v) => { if (!v) return 'Укажите email'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Неверный формат email'; return null; }, password: (v) => { if (!v) return 'Введите пароль'; if (v.length < 6) return 'Минимум 6 символов'; return null; }, age: (v) => { if (!v) return 'Укажите возраст'; if (parseInt(v) < 18) return 'Минимальный возраст 18 лет'; return null; }, agreeToTerms: (v) => !v ? 'Необходимо согласие' : null, };
const errors = Object.fromEntries( Object.entries(validators).map(([k, fn]) => [k, fn(form[k])]) ); const isValid = Object.values(errors).every(e => !e);
const getFieldStatus = (field) => { if (!touched[field]) return { border: '#334155', icon: '' }; if (errors[field]) return { border: '#dd0031', icon: '✗' }; return { border: '#22c55e', icon: '✓' }; };
const reset = () => { setForm({ username: '', email: '', password: '', age: '', agreeToTerms: false }); setTouched({}); setSubmitted(false); };
const fieldStyle = (field) => ({ width: '100%', background: '#0f172a', border: `1px solid ${getFieldStatus(field).border}`, color: '#e2e8f0', padding: '8px 12px', borderRadius: 6, fontSize: 13, boxSizing: 'border-box', outline: 'none', transition: 'border-color 0.2s' });
return ( <div style={{ background: '#0f172a', minHeight: 520, padding: 24, borderRadius: 12, fontFamily: 'system-ui', color: '#e2e8f0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}> <span style={{ color: '#dd0031', fontWeight: 700, fontSize: 16 }}>📝 Template-Driven Form</span> <span style={{ fontSize: 12, padding: '4px 10px', borderRadius: 20, background: isValid ? '#15803d40' : '#7f1d1d40', color: isValid ? '#22c55e' : '#f87171', border: `1px solid ${isValid ? '#22c55e' : '#f87171'}` }}> {isValid ? 'f.valid = true' : 'f.invalid = true'} </span> </div>
{submitted ? ( <div style={{ textAlign: 'center', padding: 40 }}> <div style={{ fontSize: 40, marginBottom: 12 }}>🎉</div> <div style={{ color: '#22c55e', fontWeight: 700, marginBottom: 16 }}>Форма отправлена!</div> <div style={{ background: '#1e293b', borderRadius: 8, padding: 16, textAlign: 'left', marginBottom: 16 }}> <div style={{ color: '#7dd3fc', fontSize: 12, marginBottom: 8 }}>f.value:</div> {Object.entries(form).map(([k, v]) => ( <div key={k} style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}> <span style={{ color: '#7dd3fc' }}>{k}</span>: <span style={{ color: '#a3e635' }}>{String(v)}</span> </div> ))} </div> <button onClick={reset} style={{ background: '#dd0031', color: 'white', border: 'none', padding: '8px 20px', borderRadius: 8, cursor: 'pointer' }}> Сбросить форму </button> </div> ) : ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> {[ { field: 'username', label: 'Имя пользователя', type: 'text', placeholder: 'yasha_dev' }, { field: 'password', label: 'Пароль', type: 'password', placeholder: '••••••' }, { field: 'age', label: 'Возраст', type: 'number', placeholder: '25' }, ].map(({ field, label, type, placeholder }) => ( <div key={field}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <label style={{ fontSize: 12, color: '#94a3b8' }}>{label}</label> <span style={{ fontSize: 12, color: touched[field] && !errors[field] ? '#22c55e' : '#dd0031' }}> {getFieldStatus(field).icon} </span> </div> <input type={type} placeholder={placeholder} value={form[field]} onChange={e => change(field, e.target.value)} onBlur={() => touch(field)} style={fieldStyle(field)} /> {touched[field] && errors[field] && ( <div style={{ color: '#f87171', fontSize: 11, marginTop: 4 }}>⚠️ {errors[field]}</div> )} </div> ))}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <input type="checkbox" checked={form.agreeToTerms} onChange={e => { change('agreeToTerms', e.target.checked); touch('agreeToTerms'); }} style={{ accentColor: '#dd0031' }} /> <label style={{ fontSize: 13, color: '#94a3b8' }}>Согласен с условиями</label> {touched['agreeToTerms'] && errors['agreeToTerms'] && ( <span style={{ color: '#f87171', fontSize: 11 }}>⚠️</span> )} </div>
<div style={{ display: 'flex', gap: 8 }}> <button onClick={() => isValid && setSubmitted(true)} disabled={!isValid} style={{ flex: 1, background: isValid ? '#dd0031' : '#334155', color: isValid ? 'white' : '#64748b', border: 'none', padding: '10px', borderRadius: 8, cursor: isValid ? 'pointer' : 'not-allowed', fontSize: 13 }} > Зарегистрироваться </button> <button onClick={reset} style={{ background: 'transparent', color: '#64748b', border: '1px solid #334155', padding: '10px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}> Сброс </button> </div> </div>
<div style={{ background: '#1e293b', borderRadius: 8, padding: 14, border: '1px solid #334155', height: 'fit-content' }}> <div style={{ color: '#7dd3fc', fontSize: 12, marginBottom: 12 }}>📊 f.value состояние</div> {Object.entries(form).map(([k, v]) => { const err = errors[k]; const isTouched = touched[k]; return ( <div key={k} style={{ marginBottom: 8, fontSize: 12 }}> <div style={{ color: '#94a3b8', marginBottom: 2 }}> <span style={{ color: '#7dd3fc' }}>{k}</span>: <span style={{ color: '#a3e635' }}>{String(v) || '""'}</span> </div> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: isTouched ? '#7c3aed40' : '#1e293b', color: isTouched ? '#a78bfa' : '#475569', border: `1px solid ${isTouched ? '#7c3aed' : '#334155'}` }}> {isTouched ? 'touched' : 'untouched'} </span> <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: err ? '#7f1d1d40' : '#15803d40', color: err ? '#f87171' : '#22c55e', border: `1px solid ${err ? '#f87171' : '#22c55e'}` }}> {err ? 'invalid' : 'valid'} </span> </div> </div> ); })} </div> </div> )} </div> );}