Browse Source

new types added. A11y updates

pull/23917/head
erdemcaygor 2 weeks ago
parent
commit
b4a44a452f
  1. 100
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html
  2. 16
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts
  3. 241
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/form-config.service.ts
  4. 319
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html
  5. 8
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.scss
  6. 28
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.ts
  7. 18
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html
  8. 19
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts
  9. 11
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.models.ts

100
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html

@ -1,6 +1,98 @@
<div class="container mt-4">
<h2>Dynamic Form Page</h2>
@if (formFields.length) {
<abp-dynamic-form [fields]="formFields" (onSubmit)="submit($event)" />
}
<div class="row mb-4">
<div class="col-12">
<h1 class="display-4">Dynamic Form Showcase</h1>
<p class="lead text-muted">
Comprehensive example demonstrating all available field types with validation and conditional logic.
</p>
<hr>
</div>
</div>
<div class="row">
<div class="col-lg-10 mx-auto">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="fas fa-wpforms me-2"></i>
User Registration Form
</h4>
<small>All 16 field types with accessibility support</small>
</div>
<div class="card-body p-4">
@if (formFields.length) {
<abp-dynamic-form
[fields]="formFields"
[submitButtonText]="'Register'"
[showCancelButton]="true"
(onSubmit)="submit($event)"
(formCancel)="cancel()" />
} @else {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading form configuration...</p>
</div>
}
</div>
</div>
<!-- Field Types Reference Card -->
<div class="card shadow-sm mt-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0">
<i class="fas fa-info-circle me-2"></i>
Available Field Types
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">Text Inputs</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success"></i> Text</li>
<li><i class="fas fa-check text-success"></i> Email</li>
<li><i class="fas fa-check text-success"></i> Password</li>
<li><i class="fas fa-check text-success"></i> Tel</li>
<li><i class="fas fa-check text-success"></i> URL</li>
<li><i class="fas fa-check text-success"></i> Textarea</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary">Special Inputs</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success"></i> Number</li>
<li><i class="fas fa-check text-success"></i> Date</li>
<li><i class="fas fa-check text-success"></i> DateTime-Local</li>
<li><i class="fas fa-check text-success"></i> Time</li>
<li><i class="fas fa-check text-success"></i> Range</li>
<li><i class="fas fa-check text-success"></i> Color</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary">Selection</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success"></i> Select (Dropdown)</li>
<li><i class="fas fa-check text-success"></i> Radio</li>
<li><i class="fas fa-check text-success"></i> Checkbox</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary">Files</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success"></i> File (Single/Multiple)</li>
</ul>
</div>
</div>
<hr>
<div class="alert alert-info mb-0">
<i class="fas fa-lightbulb me-2"></i>
<strong>Features:</strong> Full validation support, conditional logic,
grid-based layout, ARIA accessibility, keyboard navigation, and screen reader support.
</div>
</div>
</div>
</div>
</div>
</div>

16
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts

@ -19,8 +19,20 @@ export class DynamicFormPageComponent implements OnInit {
});
}
submit(val) {
console.log('submit', val);
submit(formData: any) {
console.log('✅ Form Submitted Successfully!', formData);
console.table(formData);
// Show success message
alert('✅ Form submitted successfully! Check the console for details.');
// Reset form after submission
this.dynamicFormComponent.resetForm();
}
cancel() {
console.log('❌ Form Cancelled');
alert('Form cancelled');
this.dynamicFormComponent.resetForm();
}
}

241
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/form-config.service.ts

@ -1,15 +1,244 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Injectable } from '@angular/core';
import { FormFieldConfig } from '@abp/ng.components/dynamic-form';
import { Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class FormConfigService {
protected readonly http = inject(HttpClient);
getFormConfig(): Observable<FormFieldConfig[]> {
return this.http.get<FormFieldConfig[]>('/assets/form-config.json');
const formConfig: FormFieldConfig[] = [
// Section 1: Basic Text Inputs
{
key: 'firstName',
label: 'First Name',
type: 'text',
placeholder: 'Enter your first name',
required: true,
gridSize: 6,
order: 1,
validators: [{ type: 'required', message: 'First name is required' }],
},
{
key: 'lastName',
label: 'Last Name',
type: 'text',
placeholder: 'Enter your last name',
required: true,
gridSize: 6,
order: 2,
validators: [{ type: 'required', message: 'Last name is required' }],
},
// Section 2: Email & Password
{
key: 'email',
label: 'Email Address',
type: 'email',
placeholder: 'example@domain.com',
required: true,
gridSize: 6,
order: 3,
validators: [
{ type: 'required', message: 'Email is required' },
{ type: 'email', message: 'Invalid email address' },
],
},
{
key: 'password',
label: 'Password',
type: 'password',
placeholder: 'Enter a strong password',
required: true,
gridSize: 6,
order: 4,
minLength: 8,
maxLength: 50,
validators: [
{ type: 'required', message: 'Password is required' },
{ type: 'minLength', value: 8, message: 'Password must be at least 8 characters' },
],
},
// Section 3: Contact Information
{
key: 'phone',
label: 'Phone Number',
type: 'tel',
placeholder: '555-123-4567',
gridSize: 6,
order: 5,
pattern: '[0-9]{3}-[0-9]{3}-[0-9]{4}',
},
{
key: 'website',
label: 'Website',
type: 'url',
placeholder: 'https://example.com',
gridSize: 6,
order: 6,
},
// Section 4: Numbers & Dates
{
key: 'age',
label: 'Age',
type: 'number',
placeholder: 'Enter your age',
required: true,
gridSize: 4,
order: 7,
min: 18,
max: 100,
validators: [
{ type: 'required', message: 'Age is required' },
{ type: 'min', value: 18, message: 'You must be at least 18 years old' },
],
},
{
key: 'birthdate',
label: 'Birth Date',
type: 'date',
required: true,
gridSize: 4,
order: 8,
max: new Date().toISOString().split('T')[0],
validators: [{ type: 'required', message: 'Birth date is required' }],
},
{
key: 'appointmentTime',
label: 'Appointment Date & Time',
type: 'datetime-local',
gridSize: 4,
order: 9,
min: new Date().toISOString().slice(0, 16),
},
// Section 5: Select & Radio
{
key: 'country',
label: 'Country',
type: 'select',
required: true,
gridSize: 6,
order: 10,
options: {
defaultValues: [
{ key: 'usa', value: 'United States' },
{ key: 'uk', value: 'United Kingdom' },
{ key: 'canada', value: 'Canada' },
{ key: 'germany', value: 'Germany' },
{ key: 'france', value: 'France' },
],
},
validators: [{ type: 'required', message: 'Country is required' }],
},
{
key: 'gender',
label: 'Gender',
type: 'radio',
required: true,
gridSize: 6,
order: 11,
options: {
defaultValues: [
{ key: 'male', value: 'Male' },
{ key: 'female', value: 'Female' },
{ key: 'other', value: 'Other' },
{ key: 'prefer-not-to-say', value: 'Prefer not to say' },
],
},
validators: [{ type: 'required', message: 'Gender is required' }],
},
// Section 6: Conditional Field (shown when country is USA)
{
key: 'state',
label: 'State (USA Only)',
type: 'select',
gridSize: 6,
order: 12,
options: {
defaultValues: [
{ key: 'ca', value: 'California' },
{ key: 'ny', value: 'New York' },
{ key: 'tx', value: 'Texas' },
{ key: 'fl', value: 'Florida' },
],
},
conditionalLogic: [
{ dependsOn: 'country', condition: 'equals', value: 'usa', action: 'show' },
],
},
// Section 7: Time & Range
{
key: 'preferredTime',
label: 'Preferred Contact Time',
type: 'time',
gridSize: 6,
order: 13,
step: '900', // 15 minutes
},
{
key: 'experienceLevel',
label: 'Experience Level (0-10)',
type: 'range',
gridSize: 6,
order: 14,
min: 0,
max: 10,
step: 1,
value: 5,
},
// Section 8: Color & File
{
key: 'favoriteColor',
label: 'Favorite Color',
type: 'color',
gridSize: 6,
order: 15,
value: '#007bff',
},
{
key: 'profilePicture',
label: 'Profile Picture',
type: 'file',
gridSize: 6,
order: 16,
accept: 'image/*',
multiple: false,
},
// Section 9: Textarea & Checkbox
{
key: 'bio',
label: 'Biography',
type: 'textarea',
placeholder: 'Tell us about yourself...',
gridSize: 12,
order: 17,
maxLength: 500,
},
{
key: 'newsletter',
label: 'Subscribe to newsletter',
type: 'checkbox',
gridSize: 6,
order: 18,
},
{
key: 'terms',
label: 'I agree to the terms and conditions',
type: 'checkbox',
required: true,
gridSize: 6,
order: 19,
validators: [{ type: 'requiredTrue', message: 'You must agree to the terms' }],
},
];
return of(formConfig);
}
}

319
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html

@ -1,11 +1,19 @@
@if (visible()) {
<div [formGroup]="fieldFormGroup">
<div [formGroup]="fieldFormGroup" role="group" [attr.aria-labelledby]="fieldId + '-label'">
@if (field().type === 'text') {
<!-- Text Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input [id]="field().key" [placeholder]="(field().placeholder || '') | abpLocalization" formControlName="value"
[class.is-invalid]="isInvalid" class="form-control">
<input
[id]="fieldId"
[placeholder]="(field().placeholder || '') | abpLocalization"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
@ -14,7 +22,15 @@
<!-- Select Dropdown -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<select [id]="field().key" formControlName="value" [class.is-invalid]="isInvalid" class="form-control">
<select
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
<option value="">{{ '::Select' | abpLocalization }}</option>
@for (option of options$ | async; track option.key) {
<option [value]="option.key">
@ -28,8 +44,14 @@
</div>
} @else if (field().type === 'checkbox') {
<!-- Checkbox -->
<div class="form-group form-check mb-3">
<abp-checkbox [label]="field().label | abpLocalization" formControlName="value" [id]="field().key" />
<div class="form-group form-check mb-3" role="group">
<abp-checkbox
[label]="field().label | abpLocalization"
formControlName="value"
[id]="fieldId"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null" />
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
@ -38,8 +60,18 @@
<!-- Email Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input type="email" [id]="field().key" formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization" [class.is-invalid]="isInvalid" class="form-control">
<input
type="email"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="email">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
@ -48,23 +80,286 @@
<!-- Textarea -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<textarea [id]="field().key" formControlName="value" [placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid" rows="4" class="form-control">
<textarea
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
rows="4"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
</textarea>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'number') {
<!-- Number Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="number"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.step]="field().step || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'date') {
<!-- Date Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="date"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'datetime-local') {
<!-- DateTime Local Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="datetime-local"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'time') {
<!-- Time Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="time"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.step]="field().step || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'password') {
<!-- Password Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="password"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.minlength]="field().minLength || null"
[attr.maxlength]="field().maxLength || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="new-password">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'tel') {
<!-- Tel Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="tel"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.pattern]="field().pattern || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="tel">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'url') {
<!-- URL Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="url"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="url">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'radio') {
<!-- Radio Group -->
<div class="form-group mb-3" role="radiogroup" [attr.aria-labelledby]="fieldId + '-label'">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="radio-group">
@for (option of options$ | async; track option.key) {
<div class="form-check">
<input
type="radio"
[id]="fieldId + '-' + option.key"
[value]="option.key"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-check-input"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null">
<label [for]="fieldId + '-' + option.key" class="form-check-label">
{{ option.value | abpLocalization }}
</label>
</div>
}
</div>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'file') {
<!-- File Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="file"
[id]="fieldId"
(change)="onFileChange($event)"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.accept]="field().accept || null"
[attr.multiple]="field().multiple || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'range') {
<!-- Range Slider -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="d-flex align-items-center gap-3">
<input
type="range"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-range flex-grow-1"
[attr.min]="field().min || 0"
[attr.max]="field().max || 100"
[attr.step]="field().step || 1"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
[attr.aria-valuenow]="value.value"
[attr.aria-valuemin]="field().min || 0"
[attr.aria-valuemax]="field().max || 100">
<output [for]="fieldId" class="badge bg-primary">{{ value.value }}</output>
</div>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'color') {
<!-- Color Picker -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="d-flex align-items-center gap-3">
<input
type="color"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control form-control-color"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
<code class="text-muted">{{ value.value || '#000000' }}</code>
</div>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
}
</div>
}
<ng-template #labelTemplate>
<label [for]="field().key">{{ field().label | abpLocalization }}</label>
<label
[for]="fieldId"
[id]="fieldId + '-label'"
[class.required]="field().required">
{{ field().label | abpLocalization }}
@if (field().required) {
<span class="text-danger" aria-label="required">*</span>
}
</label>
</ng-template>
<ng-template #errorTemplate>
<div class="invalid-feedback">
<div
class="invalid-feedback"
[id]="errorId"
role="alert"
aria-live="polite"
aria-atomic="true">
@for (error of errors; track error) {
<div>{{ error | abpLocalization }}</div>
}

8
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.scss

@ -1,4 +1,12 @@
// Minimal styling - rely on Bootstrap/Lepton-X theme styles
.form-group {
display: flex;
flex-direction: column;
// Radio group spacing (layout only)
.radio-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}

28
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.ts

@ -61,6 +61,19 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
private dynamicFormService = inject(DynamicFormService);
options$: Observable<{ key: string; value: any }[]> = of([]);
// Accessibility: Generate unique IDs for ARIA
get fieldId(): string {
return `field-${this.field().key}`;
}
get errorId(): string {
return `${this.fieldId}-error`;
}
get helpTextId(): string {
return `${this.fieldId}-help`;
}
constructor() {
this.fieldFormGroup = this.formBuilder.group({
@ -127,8 +140,9 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
get errors(): string[] {
if (this.control && this.control.errors) {
const errorKeys = Object.keys(this.control.errors);
const validators = this.field().validators || [];
return errorKeys.map(key => {
const validator = this.field().validators.find(
const validator = validators.find(
v => v.type.toLowerCase() === key.toLowerCase(),
);
if (validator && validator.message) {
@ -144,10 +158,22 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
return `${this.field().label} is invalid due to ${key} validation.`;
});
}
return [];
}
get value() {
return this.fieldFormGroup.get('value');
}
onFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) {
const files = Array.from(input.files);
const value = this.field().multiple ? files : files[0];
this.value.setValue(value);
this.onChange(value);
}
}
private onChange: (value: any) => void = () => { };
private onTouched: () => void = () => { };
}

18
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html

@ -1,6 +1,11 @@
<div class="form-wrapper">
<form [formGroup]="dynamicForm" (ngSubmit)="submit()" class="dynamic-form">
<div class="row">
<form
[formGroup]="dynamicForm"
(ngSubmit)="submit()"
class="dynamic-form"
role="form"
[attr.aria-label]="'Dynamic Form'">
<div class="row" role="group">
@for (field of sortedFields; track field.key) {
<div [ngClass]="'col-md-' + (field.gridSize || 12)">
@if (field.component) {
@ -26,19 +31,22 @@
</form>
<ng-template #defaultActions>
<div class="form-actions">
<div class="form-actions" role="group" aria-label="Form actions">
@if (showCancelButton()) {
<button
type="button"
class="btn btn-secondary"
(click)="onCancel()">
(click)="onCancel()"
aria-label="Cancel form">
Cancel
</button>
}
<button
type="submit"
class="btn btn-primary"
[disabled]="!dynamicForm.valid || submitInProgress()">
[disabled]="!dynamicForm.valid || submitInProgress()"
[attr.aria-busy]="submitInProgress() || null"
[attr.aria-label]="submitInProgress() ? 'Submitting form...' : submitButtonText()">
{{ submitButtonText() }}
</button>
</div>

19
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts

@ -57,6 +57,7 @@ export class DynamicFormComponent implements OnInit {
this.onSubmit.emit(this.dynamicForm.getRawValue());
} else {
this.markAllFieldsAsTouched();
this.focusFirstInvalidField();
}
}
@ -173,4 +174,22 @@ export class DynamicFormComponent implements OnInit {
this.dynamicForm.get(key)?.markAsTouched();
});
}
private focusFirstInvalidField() {
// Accessibility: Focus first invalid field for screen readers
const firstInvalidField = this.sortedFields.find(field => {
const control = this.dynamicForm.get(field.key);
return control && control.invalid && control.touched;
});
if (firstInvalidField) {
setTimeout(() => {
const element = document.getElementById(`field-${firstInvalidField.key}`);
if (element) {
element.focus();
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
}
}
}

11
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.models.ts

@ -4,7 +4,7 @@ import { ControlValueAccessor } from '@angular/forms';
export interface FormFieldConfig<T = any> {
key: string;
value?: any;
type: 'text' | 'email' | 'number' | 'select' | 'checkbox' | 'date' | 'textarea';
type: 'text' | 'email' | 'number' | 'select' | 'checkbox' | 'date' | 'textarea' | 'datetime-local' | 'time' | 'password' | 'tel' | 'url' | 'radio' | 'file' | 'range' | 'color';
label: string;
placeholder?: string;
required?: boolean;
@ -15,6 +15,15 @@ export interface FormFieldConfig<T = any> {
order?: number;
gridSize?: number;
component?: Type<ControlValueAccessor>;
// Additional field attributes
min?: number | string; // For number, date, time, range
max?: number | string; // For number, date, time, range
step?: number | string; // For number, time, range
minLength?: number; // For text, password
maxLength?: number; // For text, password
pattern?: string; // For tel, text
accept?: string; // For file input (e.g., "image/*")
multiple?: boolean; // For file input
}
export interface ValidatorConfig {

Loading…
Cancel
Save