mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-06 22:29:44 +08:00
chore: merge 8.7.4
This commit is contained in:
@ -3,6 +3,18 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.4](https://github.com/ionic-team/ionic-framework/compare/v8.7.3...v8.7.4) (2025-09-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **input:** improve error text accessibility ([#30635](https://github.com/ionic-team/ionic-framework/issues/30635)) ([c339bc3](https://github.com/ionic-team/ionic-framework/commit/c339bc36827b62ef871325869a9a5db9b17ac785))
|
||||
* **overlays,picker:** remove invalid aria-hidden attribute ([#30563](https://github.com/ionic-team/ionic-framework/issues/30563)) ([49f96d7](https://github.com/ionic-team/ionic-framework/commit/49f96d7f1e9050a95e3e33a821c0467ecc0bed64)), closes [#30040](https://github.com/ionic-team/ionic-framework/issues/30040)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.3](https://github.com/ionic-team/ionic-framework/compare/v8.7.2...v8.7.3) (2025-08-20)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular
|
||||
|
||||
12
packages/angular/package-lock.json
generated
12
packages/angular/package-lock.json
generated
@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.3",
|
||||
"@ionic/core": "^8.7.4",
|
||||
"ionicons": "^8.0.13",
|
||||
"jsonc-parser": "^3.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
@ -1398,9 +1398,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@ionic/core": {
|
||||
"version": "8.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.3.tgz",
|
||||
"integrity": "sha512-KdyMxpMDQj+uqpztpK6yvN/T96hqcDiGXQ4T+aAZ+LW3wV3+0it6/rbh9C1B/wCl4Isnm4IRltPabgEfNJ50nw==",
|
||||
"version": "8.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.4.tgz",
|
||||
"integrity": "sha512-ZCJYKLWdxq+x4OmEDvodqR+y/FSDJYkkFHozWe1+b/p0l9lNN13lLuSZVs0AEOgPtO89Atl67rTbpGE2ad/SCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.36.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.4",
|
||||
"description": "Angular specific wrappers for @ionic/core",
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@ -37,6 +37,7 @@
|
||||
"eslint": "eslint . --ext .ts",
|
||||
"prerelease": "npm run validate && np prerelease --yolo --any-branch --tag next",
|
||||
"sync": "./scripts/sync.sh",
|
||||
"local.sync.and.pack": "./scripts/sync-and-pack.sh",
|
||||
"test": "echo 'angular no tests yet'",
|
||||
"tsc": "tsc -p .",
|
||||
"validate": "npm i && npm run lint && npm run test && npm run build"
|
||||
@ -47,7 +48,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.3",
|
||||
"@ionic/core": "^8.7.4",
|
||||
"ionicons": "^8.0.13",
|
||||
"jsonc-parser": "^3.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
|
||||
30
packages/angular/scripts/sync-and-pack.sh
Executable file
30
packages/angular/scripts/sync-and-pack.sh
Executable file
@ -0,0 +1,30 @@
|
||||
set -e
|
||||
|
||||
# Delete old packages
|
||||
rm -f *.tgz
|
||||
|
||||
# Pack @ionic/core
|
||||
echo "\n📦 Packing @ionic/core..."
|
||||
npm pack ../../core
|
||||
|
||||
# Update package.json with global path for the @ionic/core package
|
||||
echo "\n⚙️ Updating package.json with global path for @ionic/core..."
|
||||
CORE_PACKAGE=$(ls ionic-core-*.tgz | head -1)
|
||||
sed -i "" "s|\"@ionic/core\": \".*\"|\"@ionic/core\": \"file:$(pwd)/$CORE_PACKAGE\"|" package.json
|
||||
|
||||
# Remove package-lock.json
|
||||
rm -f package-lock.json
|
||||
|
||||
# Install Dependencies
|
||||
echo "\n🔧 Installing dependencies..."
|
||||
npm install
|
||||
|
||||
# Build the project
|
||||
echo "\n🔨 Building the project..."
|
||||
npm run build
|
||||
|
||||
# Pack @ionic/angular
|
||||
echo "\n📦 Packing @ionic/angular..."
|
||||
npm pack ./dist
|
||||
|
||||
echo "\n✅ Packed ionic-angular package!\n $(pwd)/$(ls ionic-angular-*.tgz | head -1)\n"
|
||||
@ -28,6 +28,7 @@ import { AlertComponent } from '../alert/alert.component';
|
||||
import { AccordionComponent } from '../accordion/accordion.component';
|
||||
import { AccordionModalComponent } from '../accordion/accordion-modal/accordion-modal.component';
|
||||
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
|
||||
import { TemplateFormComponent } from '../template-form/template-form.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@ -53,7 +54,8 @@ import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
|
||||
AlertComponent,
|
||||
AccordionComponent,
|
||||
AccordionModalComponent,
|
||||
TabsBasicComponent
|
||||
TabsBasicComponent,
|
||||
TemplateFormComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
||||
@ -19,6 +19,7 @@ import { NavigationPage3Component } from '../navigation-page3/navigation-page3.c
|
||||
import { AlertComponent } from '../alert/alert.component';
|
||||
import { AccordionComponent } from '../accordion/accordion.component';
|
||||
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
|
||||
import { TemplateFormComponent } from '../template-form/template-form.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@ -33,6 +34,7 @@ export const routes: Routes = [
|
||||
{ path: 'textarea', loadChildren: () => import('../textarea/textarea.module').then(m => m.TextareaModule) },
|
||||
{ path: 'searchbar', loadChildren: () => import('../searchbar/searchbar.module').then(m => m.SearchbarModule) },
|
||||
{ path: 'form', component: FormComponent },
|
||||
{ path: 'template-form', component: TemplateFormComponent },
|
||||
{ path: 'modals', component: ModalComponent },
|
||||
{ path: 'modal-inline', loadChildren: () => import('../modal-inline').then(m => m.ModalInlineModule) },
|
||||
{ path: 'view-child', component: ViewChildComponent },
|
||||
|
||||
@ -25,6 +25,11 @@
|
||||
Form Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/lazy/template-form">
|
||||
<ion-label>
|
||||
Template-Driven Form Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/lazy/modals">
|
||||
<ion-label>
|
||||
Modals Test
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Template-Driven Form Validation Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<form #templateForm="ngForm" (ngSubmit)="onSubmit(templateForm)">
|
||||
<ion-list>
|
||||
<!-- Test ion-input with required validation -->
|
||||
<ion-item>
|
||||
<ion-input
|
||||
label="Required Input"
|
||||
[(ngModel)]="inputValue"
|
||||
name="inputField"
|
||||
required
|
||||
#inputField="ngModel"
|
||||
id="template-input-test"
|
||||
errorText="This field is required"
|
||||
helperText="Enter some text">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
|
||||
<!-- Display validation state for debugging -->
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<p>Input Touched: <span id="input-touched">{{inputField.touched}}</span></p>
|
||||
<p>Input Invalid: <span id="input-invalid">{{inputField.invalid}}</span></p>
|
||||
<p>Input Errors: <span id="input-errors">{{inputField.errors | json}}</span></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Test ion-textarea with required validation -->
|
||||
<ion-item>
|
||||
<ion-textarea
|
||||
label="Required Textarea"
|
||||
[(ngModel)]="textareaValue"
|
||||
name="textareaField"
|
||||
required
|
||||
#textareaField="ngModel"
|
||||
id="template-textarea-test"
|
||||
errorText="This field is required"
|
||||
helperText="Enter some text"
|
||||
rows="4">
|
||||
</ion-textarea>
|
||||
</ion-item>
|
||||
|
||||
<!-- Display validation state for debugging -->
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<p>Textarea Touched: <span id="textarea-touched">{{textareaField.touched}}</span></p>
|
||||
<p>Textarea Invalid: <span id="textarea-invalid">{{textareaField.invalid}}</span></p>
|
||||
<p>Textarea Errors: <span id="textarea-errors">{{textareaField.errors | json}}</span></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Additional test with minlength validation -->
|
||||
<ion-item>
|
||||
<ion-input
|
||||
label="Min Length Input (3 chars)"
|
||||
[(ngModel)]="minLengthValue"
|
||||
name="minLengthField"
|
||||
required
|
||||
minlength="3"
|
||||
#minLengthField="ngModel"
|
||||
id="template-minlength-test"
|
||||
errorText="Minimum 3 characters required"
|
||||
helperText="Enter at least 3 characters">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
|
||||
<!-- Display validation state for minlength field -->
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<p>MinLength Touched: <span id="minlength-touched">{{minLengthField.touched}}</span></p>
|
||||
<p>MinLength Invalid: <span id="minlength-invalid">{{minLengthField.invalid}}</span></p>
|
||||
<p>MinLength Errors: <span id="minlength-errors">{{minLengthField.errors | json}}</span></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<div class="ion-padding">
|
||||
<p>Form Valid: <span id="form-valid">{{templateForm.valid}}</span></p>
|
||||
<p>Form Submitted: <span id="form-submitted">{{submitted}}</span></p>
|
||||
|
||||
<ion-button type="submit" id="submit-button" [disabled]="!templateForm.valid">
|
||||
Submit Form
|
||||
</ion-button>
|
||||
|
||||
<ion-button type="button" id="reset-button" (click)="resetForm(templateForm)">
|
||||
Reset Form
|
||||
</ion-button>
|
||||
|
||||
<ion-button type="button" id="touch-all-button" (click)="templateForm.form.markAllAsTouched()">
|
||||
Mark All as Touched
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div class="ion-padding">
|
||||
<h3>Form Values:</h3>
|
||||
<pre id="form-values">{{templateForm.value | json}}</pre>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ion-padding">
|
||||
<h3>Instructions to reproduce issue:</h3>
|
||||
<ol>
|
||||
<li>Click in the "Required Input" field</li>
|
||||
<li>Click outside without entering text</li>
|
||||
<li>The field should show as touched and invalid</li>
|
||||
<li>The error text should appear below the input</li>
|
||||
<li>For screen readers, the validation state should be announced</li>
|
||||
</ol>
|
||||
<p><strong>Note:</strong> With template-driven forms, Angular applies validation classes to the wrapper element, not directly to ion-input/ion-textarea.</p>
|
||||
</div>
|
||||
</ion-content>
|
||||
@ -0,0 +1,26 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-template-form',
|
||||
templateUrl: './template-form.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class TemplateFormComponent {
|
||||
inputValue = '';
|
||||
textareaValue = '';
|
||||
minLengthValue = '';
|
||||
|
||||
// Track if form has been submitted
|
||||
submitted = false;
|
||||
|
||||
onSubmit(form: any) {
|
||||
this.submitted = true;
|
||||
console.log('Form submitted:', form.value);
|
||||
console.log('Form valid:', form.valid);
|
||||
}
|
||||
|
||||
resetForm(form: any) {
|
||||
form.reset();
|
||||
this.submitted = false;
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,14 @@ export const routes: Routes = [
|
||||
]
|
||||
},
|
||||
{ path: 'tabs-basic', loadComponent: () => import('../tabs-basic/tabs-basic.component').then(c => c.TabsBasicComponent) },
|
||||
{
|
||||
path: 'validation',
|
||||
children: [
|
||||
{ path: 'input-validation', loadComponent: () => import('../validation/input-validation/input-validation.component').then(c => c.InputValidationComponent) },
|
||||
{ path: 'textarea-validation', loadComponent: () => import('../validation/textarea-validation/textarea-validation.component').then(c => c.TextareaValidationComponent) },
|
||||
{ path: '**', redirectTo: 'input-validation' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'value-accessors',
|
||||
children: [
|
||||
|
||||
@ -107,6 +107,22 @@
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list>
|
||||
<ion-list-header>
|
||||
<ion-label>Validation Tests</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-item routerLink="/standalone/validation/input-validation">
|
||||
<ion-label>
|
||||
Input Validation Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/validation/textarea-validation">
|
||||
<ion-label>
|
||||
Textarea Validation Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list>
|
||||
<ion-list-header>
|
||||
<ion-label>Value Accessors</ion-label>
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Input - Validation Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="validation-info">
|
||||
<h2>Screen Reader Testing Instructions:</h2>
|
||||
<ol>
|
||||
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
|
||||
<li>Tab through the form fields</li>
|
||||
<li>When you tab away from an empty required field, the error should be announced immediately</li>
|
||||
<li>The error text should be announced BEFORE the next field is announced</li>
|
||||
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>Required Email Field</h2>
|
||||
<ion-input
|
||||
#emailInput
|
||||
id="email-input"
|
||||
type="email"
|
||||
label="Email"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your email"
|
||||
[helperText]="fieldMetadata.email.helperText"
|
||||
[errorText]="fieldMetadata.email.errorText"
|
||||
formControlName="email"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Required Name Field</h2>
|
||||
<ion-input
|
||||
#nameInput
|
||||
id="name-input"
|
||||
type="text"
|
||||
label="Full Name"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your full name"
|
||||
[helperText]="fieldMetadata.name.helperText"
|
||||
[errorText]="fieldMetadata.name.errorText"
|
||||
formControlName="name"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Phone Number (Pattern Validation)</h2>
|
||||
<ion-input
|
||||
#phoneInput
|
||||
id="phone-input"
|
||||
type="tel"
|
||||
label="Phone"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="(555) 555-5555"
|
||||
pattern="^\(\d{3}\) \d{3}-\d{4}$"
|
||||
[helperText]="fieldMetadata.phone.helperText"
|
||||
[errorText]="fieldMetadata.phone.errorText"
|
||||
formControlName="phone"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Password (Min Length)</h2>
|
||||
<ion-input
|
||||
#passwordInput
|
||||
id="password-input"
|
||||
type="password"
|
||||
label="Password"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter password"
|
||||
minlength="8"
|
||||
[helperText]="fieldMetadata.password.helperText"
|
||||
[errorText]="fieldMetadata.password.errorText"
|
||||
formControlName="password"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Age (Number Range)</h2>
|
||||
<ion-input
|
||||
#ageInput
|
||||
id="age-input"
|
||||
type="number"
|
||||
label="Age"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your age"
|
||||
min="18"
|
||||
max="120"
|
||||
[helperText]="fieldMetadata.age.helperText"
|
||||
[errorText]="fieldMetadata.age.errorText"
|
||||
formControlName="age"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Optional Field (No Validation)</h2>
|
||||
<ion-input
|
||||
#optionalInput
|
||||
id="optional-input"
|
||||
type="text"
|
||||
label="Optional Info"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="This field is optional"
|
||||
[helperText]="fieldMetadata.optional.helperText"
|
||||
formControlName="optional"
|
||||
></ion-input>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ion-padding">
|
||||
<ion-button id="submit-btn" expand="block" [disabled]="form.invalid" (click)="onSubmit()">Submit Form</ion-button>
|
||||
<ion-button id="reset-btn" expand="block" fill="outline" (click)="form.reset()">Reset Form</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
@ -0,0 +1,36 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-row-gap: 20px;
|
||||
grid-column-gap: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: var(--ion-color-step-600);
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.validation-info {
|
||||
margin: 20px;
|
||||
padding: 10px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.validation-info h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.validation-info ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.validation-info li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonTitle,
|
||||
IonToolbar
|
||||
} from '@ionic/angular/standalone';
|
||||
|
||||
@Component({
|
||||
selector: 'app-input-validation',
|
||||
templateUrl: './input-validation.component.html',
|
||||
styleUrls: ['./input-validation.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
IonInput,
|
||||
IonButton,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonContent
|
||||
]
|
||||
})
|
||||
export class InputValidationComponent {
|
||||
// Field metadata for labels and error messages
|
||||
fieldMetadata = {
|
||||
email: {
|
||||
label: 'Email',
|
||||
helperText: "We'll never share your email",
|
||||
errorText: 'Please enter a valid email address'
|
||||
},
|
||||
name: {
|
||||
label: 'Full Name',
|
||||
helperText: 'First and last name',
|
||||
errorText: 'Name is required'
|
||||
},
|
||||
phone: {
|
||||
label: 'Phone',
|
||||
helperText: 'Format: (555) 555-5555',
|
||||
errorText: 'Please enter a valid phone number'
|
||||
},
|
||||
password: {
|
||||
label: 'Password',
|
||||
helperText: 'At least 8 characters',
|
||||
errorText: 'Password must be at least 8 characters'
|
||||
},
|
||||
age: {
|
||||
label: 'Age',
|
||||
helperText: 'Must be 18 or older',
|
||||
errorText: 'Please enter a valid age (18-120)'
|
||||
},
|
||||
optional: {
|
||||
label: 'Optional Info',
|
||||
helperText: 'You can skip this field',
|
||||
errorText: ''
|
||||
}
|
||||
};
|
||||
|
||||
form = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
name: ['', Validators.required],
|
||||
phone: ['', [Validators.required, Validators.pattern(/^\(\d{3}\) \d{3}-\d{4}$/)]],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||
age: ['', [Validators.required, Validators.min(18), Validators.max(120)]],
|
||||
optional: ['']
|
||||
});
|
||||
|
||||
constructor(private fb: FormBuilder) {}
|
||||
|
||||
// Submit form
|
||||
onSubmit(): void {
|
||||
if (this.form.valid) {
|
||||
alert('Form submitted successfully!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Textarea - Validation Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="validation-info">
|
||||
<h2>Screen Reader Testing Instructions:</h2>
|
||||
<ol>
|
||||
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
|
||||
<li>Tab through the form fields</li>
|
||||
<li>When you tab away from an empty required field, the error should be announced immediately</li>
|
||||
<li>The error text should be announced BEFORE the next field is announced</li>
|
||||
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>Required Description (Min Length)</h2>
|
||||
<ion-textarea
|
||||
#descriptionTextarea
|
||||
id="description-textarea"
|
||||
[label]="fieldMetadata.description.label"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter a description"
|
||||
[rows]="fieldMetadata.description.rows"
|
||||
minlength="20"
|
||||
[helperText]="fieldMetadata.description.helperText"
|
||||
[errorText]="fieldMetadata.description.errorText"
|
||||
formControlName="description"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Required Comments</h2>
|
||||
<ion-textarea
|
||||
#commentsTextarea
|
||||
id="comments-textarea"
|
||||
[label]="fieldMetadata.comments.label"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your comments"
|
||||
[rows]="fieldMetadata.comments.rows"
|
||||
[helperText]="fieldMetadata.comments.helperText"
|
||||
[errorText]="fieldMetadata.comments.errorText"
|
||||
formControlName="comments"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Bio (Max Length)</h2>
|
||||
<ion-textarea
|
||||
#bioTextarea
|
||||
id="bio-textarea"
|
||||
[label]="fieldMetadata.bio.label"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Tell us about yourself"
|
||||
[rows]="fieldMetadata.bio.rows"
|
||||
maxlength="200"
|
||||
[counter]="fieldMetadata.bio.counter"
|
||||
[helperText]="fieldMetadata.bio.helperText"
|
||||
[errorText]="fieldMetadata.bio.errorText"
|
||||
formControlName="bio"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Address (Pattern Validation)</h2>
|
||||
<ion-textarea
|
||||
#addressTextarea
|
||||
id="address-textarea"
|
||||
[label]="fieldMetadata.address.label"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your full address"
|
||||
[rows]="fieldMetadata.address.rows"
|
||||
[helperText]="fieldMetadata.address.helperText"
|
||||
[errorText]="fieldMetadata.address.errorText"
|
||||
formControlName="address"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Review (Min/Max Length)</h2>
|
||||
<ion-textarea
|
||||
#reviewTextarea
|
||||
id="review-textarea"
|
||||
[label]="fieldMetadata.review.label"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Write your review"
|
||||
[rows]="fieldMetadata.review.rows"
|
||||
minlength="50"
|
||||
maxlength="500"
|
||||
[counter]="fieldMetadata.review.counter"
|
||||
[helperText]="fieldMetadata.review.helperText"
|
||||
[errorText]="fieldMetadata.review.errorText"
|
||||
formControlName="review"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Optional Notes</h2>
|
||||
<ion-textarea
|
||||
#notesTextarea
|
||||
id="notes-textarea"
|
||||
[label]="fieldMetadata.notes.label"
|
||||
labelPlacement="floating"
|
||||
fill="outline"
|
||||
placeholder="Any additional notes (optional)"
|
||||
[rows]="fieldMetadata.notes.rows"
|
||||
[helperText]="fieldMetadata.notes.helperText"
|
||||
formControlName="notes"
|
||||
></ion-textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ion-padding">
|
||||
<ion-button id="submit-btn" expand="block" [disabled]="form.invalid" (click)="onSubmit()">Submit Form</ion-button>
|
||||
<ion-button id="reset-btn" expand="block" fill="outline" (click)="form.reset()">Reset Form</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
@ -0,0 +1,36 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-row-gap: 20px;
|
||||
grid-column-gap: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: var(--ion-color-step-600);
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.validation-info {
|
||||
margin: 20px;
|
||||
padding: 10px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.validation-info h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.validation-info ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.validation-info li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonTextarea,
|
||||
IonTitle,
|
||||
IonToolbar
|
||||
} from '@ionic/angular/standalone';
|
||||
|
||||
// Custom validator for address (must be at least 10 chars and contain a digit)
|
||||
function addressValidator(control: AbstractControl): ValidationErrors | null {
|
||||
const value = control.value;
|
||||
if (!value || value.length < 10) {
|
||||
return { invalidAddress: true };
|
||||
}
|
||||
// Check if it contains at least one number (for street/zip)
|
||||
return /\d/.test(value) ? null : { invalidAddress: true };
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-textarea-validation',
|
||||
templateUrl: './textarea-validation.component.html',
|
||||
styleUrls: ['./textarea-validation.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
IonTextarea,
|
||||
IonButton,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonContent
|
||||
]
|
||||
})
|
||||
export class TextareaValidationComponent {
|
||||
// Field metadata for labels and error messages
|
||||
fieldMetadata = {
|
||||
description: {
|
||||
label: 'Description',
|
||||
helperText: 'At least 20 characters',
|
||||
errorText: 'Description must be at least 20 characters',
|
||||
rows: 4
|
||||
},
|
||||
comments: {
|
||||
label: 'Comments',
|
||||
helperText: 'Please provide your feedback',
|
||||
errorText: 'Comments are required',
|
||||
rows: 4
|
||||
},
|
||||
bio: {
|
||||
label: 'Bio',
|
||||
helperText: 'Maximum 200 characters',
|
||||
errorText: 'Bio is required',
|
||||
rows: 4,
|
||||
counter: true
|
||||
},
|
||||
address: {
|
||||
label: 'Address',
|
||||
helperText: 'Include street, city, state, and zip',
|
||||
errorText: 'Please enter a complete address',
|
||||
rows: 3
|
||||
},
|
||||
review: {
|
||||
label: 'Product Review',
|
||||
helperText: 'Between 50-500 characters',
|
||||
errorText: 'Review must be between 50-500 characters',
|
||||
rows: 5,
|
||||
counter: true
|
||||
},
|
||||
notes: {
|
||||
label: 'Additional Notes',
|
||||
helperText: 'This field is optional',
|
||||
errorText: '',
|
||||
rows: 3
|
||||
}
|
||||
};
|
||||
|
||||
form = this.fb.group({
|
||||
description: ['', [Validators.required, Validators.minLength(20)]],
|
||||
comments: ['', Validators.required],
|
||||
bio: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
address: ['', [Validators.required, addressValidator]],
|
||||
review: ['', [Validators.required, Validators.minLength(50), Validators.maxLength(500)]],
|
||||
notes: ['']
|
||||
});
|
||||
|
||||
constructor(private fb: FormBuilder) {}
|
||||
|
||||
// Submit form
|
||||
onSubmit(): void {
|
||||
if (this.form.valid) {
|
||||
alert('Form submitted successfully!');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user