@ionic/core 8.7.3 → 8.7.4-dev.11755809082.11a7702b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ion-input.js +63 -8
- package/components/ion-textarea.js +62 -7
- package/dist/cjs/ion-input.cjs.entry.js +62 -8
- package/dist/cjs/ion-textarea.cjs.entry.js +61 -7
- package/dist/cjs/ionic.cjs.js +1 -1
- package/dist/cjs/loader.cjs.js +1 -1
- package/dist/collection/components/input/input.js +64 -9
- package/dist/collection/components/textarea/textarea.js +63 -8
- package/dist/docs.json +1 -1
- package/dist/esm/ion-input.entry.js +62 -8
- package/dist/esm/ion-textarea.entry.js +61 -7
- package/dist/esm/ionic.js +1 -1
- package/dist/esm/loader.js +1 -1
- package/dist/ionic/ionic.esm.js +1 -1
- package/dist/ionic/p-1d65c601.entry.js +4 -0
- package/dist/ionic/p-33017191.entry.js +4 -0
- package/dist/types/components/input/input.d.ts +9 -0
- package/dist/types/components/textarea/textarea.d.ts +9 -0
- package/hydrate/index.js +93 -15
- package/hydrate/index.mjs +93 -15
- package/package.json +1 -1
- package/dist/ionic/p-1488b7cc.entry.js +0 -4
- package/dist/ionic/p-c5210d3e.entry.js +0 -4
package/components/ion-input.js
CHANGED
|
@@ -43,6 +43,10 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
43
43
|
* is applied in both cases.
|
|
44
44
|
*/
|
|
45
45
|
this.hasFocus = false;
|
|
46
|
+
/**
|
|
47
|
+
* Track validation state for proper aria-live announcements
|
|
48
|
+
*/
|
|
49
|
+
this.isInvalid = false;
|
|
46
50
|
/**
|
|
47
51
|
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
|
|
48
52
|
* Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
|
|
@@ -128,6 +132,19 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
128
132
|
}
|
|
129
133
|
this.didInputClearOnEdit = false;
|
|
130
134
|
this.ionBlur.emit(ev);
|
|
135
|
+
/**
|
|
136
|
+
* Check validation state after blur to handle framework-managed classes.
|
|
137
|
+
* Frameworks like Angular update classes asynchronously, often using
|
|
138
|
+
* requestAnimationFrame or promises. Using setTimeout ensures we check
|
|
139
|
+
* after all microtasks and animation frames have completed.
|
|
140
|
+
*/
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
const newIsInvalid = this.checkValidationState();
|
|
143
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
144
|
+
this.isInvalid = newIsInvalid;
|
|
145
|
+
forceUpdate(this);
|
|
146
|
+
}
|
|
147
|
+
}, 100);
|
|
131
148
|
};
|
|
132
149
|
this.onFocus = (ev) => {
|
|
133
150
|
this.hasFocus = true;
|
|
@@ -227,10 +244,42 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
227
244
|
componentWillLoad() {
|
|
228
245
|
this.inheritedAttributes = Object.assign(Object.assign({}, inheritAriaAttributes(this.el)), inheritAttributes(this.el, ['tabindex', 'title', 'data-form-type', 'dir']));
|
|
229
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Checks if the input is in an invalid state based on validation classes
|
|
249
|
+
*/
|
|
250
|
+
checkValidationState() {
|
|
251
|
+
// Check for both Ionic and Angular validation classes on the element itself
|
|
252
|
+
// Angular applies ng-touched/ng-invalid directly to the host element with ngModel
|
|
253
|
+
const hasIonTouched = this.el.classList.contains('ion-touched');
|
|
254
|
+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
|
|
255
|
+
const hasNgTouched = this.el.classList.contains('ng-touched');
|
|
256
|
+
const hasNgInvalid = this.el.classList.contains('ng-invalid');
|
|
257
|
+
// Return true if we have both touched and invalid states from either framework
|
|
258
|
+
const isTouched = hasIonTouched || hasNgTouched;
|
|
259
|
+
const isInvalid = hasIonInvalid || hasNgInvalid;
|
|
260
|
+
return isTouched && isInvalid;
|
|
261
|
+
}
|
|
230
262
|
connectedCallback() {
|
|
231
263
|
const { el } = this;
|
|
232
264
|
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
|
|
233
265
|
this.notchController = createNotchController(el, () => this.notchSpacerEl, () => this.labelSlot);
|
|
266
|
+
// Watch for class changes to update validation state
|
|
267
|
+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
|
|
268
|
+
this.validationObserver = new MutationObserver(() => {
|
|
269
|
+
const newIsInvalid = this.checkValidationState();
|
|
270
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
271
|
+
this.isInvalid = newIsInvalid;
|
|
272
|
+
// Force a re-render to update aria-describedby immediately
|
|
273
|
+
forceUpdate(this);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
this.validationObserver.observe(el, {
|
|
277
|
+
attributes: true,
|
|
278
|
+
attributeFilter: ['class'],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Always set initial state
|
|
282
|
+
this.isInvalid = this.checkValidationState();
|
|
234
283
|
this.debounceChanged();
|
|
235
284
|
if (Build.isBrowser) {
|
|
236
285
|
document.dispatchEvent(new CustomEvent('ionInputDidLoad', {
|
|
@@ -267,6 +316,11 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
267
316
|
this.notchController.destroy();
|
|
268
317
|
this.notchController = undefined;
|
|
269
318
|
}
|
|
319
|
+
// Clean up validation observer to prevent memory leaks
|
|
320
|
+
if (this.validationObserver) {
|
|
321
|
+
this.validationObserver.disconnect();
|
|
322
|
+
this.validationObserver = undefined;
|
|
323
|
+
}
|
|
270
324
|
}
|
|
271
325
|
/**
|
|
272
326
|
* Sets focus on the native `input` in `ion-input`. Use this method instead of the global
|
|
@@ -368,15 +422,15 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
368
422
|
* Renders the helper text or error text values
|
|
369
423
|
*/
|
|
370
424
|
renderHintText() {
|
|
371
|
-
const { helperText, errorText, helperTextId, errorTextId } = this;
|
|
425
|
+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
|
372
426
|
return [
|
|
373
|
-
h("div", { id: helperTextId, class: "helper-text" }, helperText),
|
|
374
|
-
h("div", { id: errorTextId, class: "error-text" }, errorText),
|
|
427
|
+
helperText && (h("div", { id: helperTextId, class: "helper-text", "aria-live": "polite" }, helperText)),
|
|
428
|
+
errorText && (h("div", { id: errorTextId, class: "error-text", "aria-live": "assertive", "aria-atomic": "true", role: "alert", style: { display: isInvalid ? 'block' : 'none' } }, errorText)),
|
|
375
429
|
];
|
|
376
430
|
}
|
|
377
431
|
getHintTextID() {
|
|
378
|
-
const {
|
|
379
|
-
if (
|
|
432
|
+
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
|
|
433
|
+
if (isInvalid && errorText) {
|
|
380
434
|
return errorTextId;
|
|
381
435
|
}
|
|
382
436
|
if (helperText) {
|
|
@@ -489,7 +543,7 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
489
543
|
* TODO(FW-5592): Remove hasStartEndSlots condition
|
|
490
544
|
*/
|
|
491
545
|
const labelShouldFloat = labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
|
492
|
-
return (h(Host, { key: '
|
|
546
|
+
return (h(Host, { key: '1a141906096fc637d7de923edf6a9ea3a0168f5f', class: createColorClasses(this.color, {
|
|
493
547
|
[mode]: true,
|
|
494
548
|
'has-value': hasValue,
|
|
495
549
|
'has-focus': hasFocus,
|
|
@@ -500,14 +554,14 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
500
554
|
'in-item': inItem,
|
|
501
555
|
'in-item-color': hostContext('ion-item.ion-color', this.el),
|
|
502
556
|
'input-disabled': disabled,
|
|
503
|
-
}) }, h("label", { key: '
|
|
557
|
+
}) }, h("label", { key: '8371432bc0fd37099d3efc50705530965f27b9e1', class: "input-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), h("div", { key: '3d113da062bddf47b1bc67e67162c11fe4ee7f10', class: "native-wrapper", onClick: this.onLabelClick }, h("slot", { key: '3fe52ada4b91ebd21b21b1445b9d4009d47f8386', name: "start" }), h("input", Object.assign({ key: 'beda905e9d44797690149407ba814381ffd51d1d', class: "native-input", ref: (input) => (this.nativeInput = input), id: inputId, disabled: disabled, autoCapitalize: this.autocapitalize, autoComplete: this.autocomplete, autoCorrect: this.autocorrect, autoFocus: this.autofocus, enterKeyHint: this.enterkeyhint, inputMode: this.inputmode, min: this.min, max: this.max, minLength: this.minlength, maxLength: this.maxlength, multiple: this.multiple, name: this.name, pattern: this.pattern, placeholder: this.placeholder || '', readOnly: readonly, required: this.required, spellcheck: this.spellcheck, step: this.step, type: this.type, value: value, onInput: this.onInput, onChange: this.onChange, onBlur: this.onBlur, onFocus: this.onFocus, onKeyDown: this.onKeydown, onCompositionstart: this.onCompositionStart, onCompositionend: this.onCompositionEnd, "aria-describedby": this.getHintTextID(), "aria-invalid": this.isInvalid ? 'true' : undefined }, this.inheritedAttributes)), this.clearInput && !readonly && !disabled && (h("button", { key: 'bc8579a0acb727b348bcf277f296b2dac9cb583d', "aria-label": "reset", type: "button", class: "input-clear-icon", onPointerDown: (ev) => {
|
|
504
558
|
/**
|
|
505
559
|
* This prevents mobile browsers from
|
|
506
560
|
* blurring the input when the clear
|
|
507
561
|
* button is activated.
|
|
508
562
|
*/
|
|
509
563
|
ev.preventDefault();
|
|
510
|
-
}, onClick: this.clearTextInput }, h("ion-icon", { key: '
|
|
564
|
+
}, onClick: this.clearTextInput }, h("ion-icon", { key: '41a64ec6fce010eb225637998d93abd859a62be7', "aria-hidden": "true", icon: clearIconData }))), h("slot", { key: '9a7e6eba584748fc037aa1694d7e6e399de8b20b', name: "end" })), shouldRenderHighlight && h("div", { key: 'b828e6dc43b50f46727dda196de7ad2fecc3ef30', class: "input-highlight" })), this.renderBottomContent()));
|
|
511
565
|
}
|
|
512
566
|
get el() { return this; }
|
|
513
567
|
static get watchers() { return {
|
|
@@ -556,6 +610,7 @@ const Input = /*@__PURE__*/ proxyCustomElement(class Input extends HTMLElement {
|
|
|
556
610
|
"type": [1],
|
|
557
611
|
"value": [1032],
|
|
558
612
|
"hasFocus": [32],
|
|
613
|
+
"isInvalid": [32],
|
|
559
614
|
"setFocus": [64],
|
|
560
615
|
"getInputElement": [64]
|
|
561
616
|
}, [[2, "click", "onClickCapture"]], {
|
|
@@ -40,6 +40,10 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
40
40
|
* is applied in both cases.
|
|
41
41
|
*/
|
|
42
42
|
this.hasFocus = false;
|
|
43
|
+
/**
|
|
44
|
+
* Track validation state for proper aria-live announcements
|
|
45
|
+
*/
|
|
46
|
+
this.isInvalid = false;
|
|
43
47
|
/**
|
|
44
48
|
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
|
|
45
49
|
* Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
|
|
@@ -129,6 +133,19 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
129
133
|
}
|
|
130
134
|
this.didTextareaClearOnEdit = false;
|
|
131
135
|
this.ionBlur.emit(ev);
|
|
136
|
+
/**
|
|
137
|
+
* Check validation state after blur to handle framework-managed classes.
|
|
138
|
+
* Frameworks like Angular update classes asynchronously, often using
|
|
139
|
+
* requestAnimationFrame or promises. Using setTimeout ensures we check
|
|
140
|
+
* after all microtasks and animation frames have completed.
|
|
141
|
+
*/
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
const newIsInvalid = this.checkValidationState();
|
|
144
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
145
|
+
this.isInvalid = newIsInvalid;
|
|
146
|
+
forceUpdate(this);
|
|
147
|
+
}
|
|
148
|
+
}, 100);
|
|
132
149
|
};
|
|
133
150
|
this.onKeyDown = (ev) => {
|
|
134
151
|
this.checkClearOnEdit(ev);
|
|
@@ -186,10 +203,42 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
186
203
|
this.el.click();
|
|
187
204
|
}
|
|
188
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Checks if the textarea is in an invalid state based on validation classes
|
|
208
|
+
*/
|
|
209
|
+
checkValidationState() {
|
|
210
|
+
// Check for both Ionic and Angular validation classes on the element itself
|
|
211
|
+
// Angular applies ng-touched/ng-invalid directly to the host element with ngModel
|
|
212
|
+
const hasIonTouched = this.el.classList.contains('ion-touched');
|
|
213
|
+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
|
|
214
|
+
const hasNgTouched = this.el.classList.contains('ng-touched');
|
|
215
|
+
const hasNgInvalid = this.el.classList.contains('ng-invalid');
|
|
216
|
+
// Return true if we have both touched and invalid states from either framework
|
|
217
|
+
const isTouched = hasIonTouched || hasNgTouched;
|
|
218
|
+
const isInvalid = hasIonInvalid || hasNgInvalid;
|
|
219
|
+
return isTouched && isInvalid;
|
|
220
|
+
}
|
|
189
221
|
connectedCallback() {
|
|
190
222
|
const { el } = this;
|
|
191
223
|
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
|
|
192
224
|
this.notchController = createNotchController(el, () => this.notchSpacerEl, () => this.labelSlot);
|
|
225
|
+
// Watch for class changes to update validation state
|
|
226
|
+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
|
|
227
|
+
this.validationObserver = new MutationObserver(() => {
|
|
228
|
+
const newIsInvalid = this.checkValidationState();
|
|
229
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
230
|
+
this.isInvalid = newIsInvalid;
|
|
231
|
+
// Force a re-render to update aria-describedby immediately
|
|
232
|
+
forceUpdate(this);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
this.validationObserver.observe(el, {
|
|
236
|
+
attributes: true,
|
|
237
|
+
attributeFilter: ['class'],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// Always set initial state
|
|
241
|
+
this.isInvalid = this.checkValidationState();
|
|
193
242
|
this.debounceChanged();
|
|
194
243
|
if (Build.isBrowser) {
|
|
195
244
|
document.dispatchEvent(new CustomEvent('ionInputDidLoad', {
|
|
@@ -211,6 +260,11 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
211
260
|
this.notchController.destroy();
|
|
212
261
|
this.notchController = undefined;
|
|
213
262
|
}
|
|
263
|
+
// Clean up validation observer to prevent memory leaks
|
|
264
|
+
if (this.validationObserver) {
|
|
265
|
+
this.validationObserver.disconnect();
|
|
266
|
+
this.validationObserver = undefined;
|
|
267
|
+
}
|
|
214
268
|
}
|
|
215
269
|
componentWillLoad() {
|
|
216
270
|
this.inheritedAttributes = Object.assign(Object.assign({}, inheritAriaAttributes(this.el)), inheritAttributes(this.el, ['data-form-type', 'title', 'tabindex', 'dir']));
|
|
@@ -381,15 +435,15 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
381
435
|
* Renders the helper text or error text values
|
|
382
436
|
*/
|
|
383
437
|
renderHintText() {
|
|
384
|
-
const { helperText, errorText, helperTextId, errorTextId } = this;
|
|
438
|
+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
|
385
439
|
return [
|
|
386
|
-
h("div", { id: helperTextId, class: "helper-text" }, helperText),
|
|
387
|
-
h("div", { id: errorTextId, class: "error-text" }, errorText),
|
|
440
|
+
helperText && (h("div", { id: helperTextId, class: "helper-text", "aria-live": "polite" }, helperText)),
|
|
441
|
+
errorText && (h("div", { id: errorTextId, class: "error-text", "aria-live": "assertive", "aria-atomic": "true", role: "alert", style: { display: isInvalid ? 'block' : 'none' } }, errorText)),
|
|
388
442
|
];
|
|
389
443
|
}
|
|
390
444
|
getHintTextID() {
|
|
391
|
-
const {
|
|
392
|
-
if (
|
|
445
|
+
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
|
|
446
|
+
if (isInvalid && errorText) {
|
|
393
447
|
return errorTextId;
|
|
394
448
|
}
|
|
395
449
|
if (helperText) {
|
|
@@ -448,7 +502,7 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
448
502
|
* TODO(FW-5592): Remove hasStartEndSlots condition
|
|
449
503
|
*/
|
|
450
504
|
const labelShouldFloat = labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
|
451
|
-
return (h(Host, { key: '
|
|
505
|
+
return (h(Host, { key: '803c7648de15b8569c3df01692548018cc660510', class: createColorClasses(this.color, {
|
|
452
506
|
[mode]: true,
|
|
453
507
|
'has-value': hasValue,
|
|
454
508
|
'has-focus': hasFocus,
|
|
@@ -457,7 +511,7 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
457
511
|
[`textarea-shape-${shape}`]: shape !== undefined,
|
|
458
512
|
[`textarea-label-placement-${labelPlacement}`]: true,
|
|
459
513
|
'textarea-disabled': disabled,
|
|
460
|
-
}) }, h("label", { key: '
|
|
514
|
+
}) }, h("label", { key: '0e3a4e5fc809437abc6780eb7f99a0c770bb9f94', class: "textarea-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), h("div", { key: 'ff8d71e2d53daf4999338232d3b0a597627a3276', class: "textarea-wrapper-inner" }, h("div", { key: '313f5a8dc8b62e8ae6a0638a7e201b2594c180eb', class: "start-slot-wrapper" }, h("slot", { key: '32db088d41398be935f7060138e4d80a832fea85', name: "start" })), h("div", { key: '24b63ff40ffcae8816f24d9c8a6ec2e7848e0e0b', class: "native-wrapper", ref: (el) => (this.textareaWrapper = el) }, h("textarea", Object.assign({ key: '2a4f918351f87d444d8922254eefa0941a6ca238', class: "native-textarea", ref: (el) => (this.nativeInput = el), id: inputId, disabled: disabled, autoCapitalize: this.autocapitalize, autoFocus: this.autofocus, enterKeyHint: this.enterkeyhint, inputMode: this.inputmode, minLength: this.minlength, maxLength: this.maxlength, name: this.name, placeholder: this.placeholder || '', readOnly: this.readonly, required: this.required, spellcheck: this.spellcheck, cols: this.cols, rows: this.rows, wrap: this.wrap, onInput: this.onInput, onChange: this.onChange, onBlur: this.onBlur, onFocus: this.onFocus, onKeyDown: this.onKeyDown, "aria-describedby": this.getHintTextID(), "aria-invalid": this.isInvalid ? 'true' : undefined }, this.inheritedAttributes), value)), h("div", { key: '2c32636d2d13560de0a54507e1668eb9f4bcc2cf', class: "end-slot-wrapper" }, h("slot", { key: 'ccc3549246fd9d2e309958767a7a24e1be8545fd', name: "end" }))), shouldRenderHighlight && h("div", { key: 'f4719399ec530ce295b19817688b8d39d84b6b09', class: "textarea-highlight" })), this.renderBottomContent()));
|
|
461
515
|
}
|
|
462
516
|
get el() { return this; }
|
|
463
517
|
static get watchers() { return {
|
|
@@ -499,6 +553,7 @@ const Textarea = /*@__PURE__*/ proxyCustomElement(class Textarea extends HTMLEle
|
|
|
499
553
|
"labelPlacement": [1, "label-placement"],
|
|
500
554
|
"shape": [1],
|
|
501
555
|
"hasFocus": [32],
|
|
556
|
+
"isInvalid": [32],
|
|
502
557
|
"setFocus": [64],
|
|
503
558
|
"getInputElement": [64]
|
|
504
559
|
}, [[2, "click", "onClickCapture"]], {
|
|
@@ -44,6 +44,10 @@ const Input = class {
|
|
|
44
44
|
* is applied in both cases.
|
|
45
45
|
*/
|
|
46
46
|
this.hasFocus = false;
|
|
47
|
+
/**
|
|
48
|
+
* Track validation state for proper aria-live announcements
|
|
49
|
+
*/
|
|
50
|
+
this.isInvalid = false;
|
|
47
51
|
/**
|
|
48
52
|
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
|
|
49
53
|
* Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
|
|
@@ -129,6 +133,19 @@ const Input = class {
|
|
|
129
133
|
}
|
|
130
134
|
this.didInputClearOnEdit = false;
|
|
131
135
|
this.ionBlur.emit(ev);
|
|
136
|
+
/**
|
|
137
|
+
* Check validation state after blur to handle framework-managed classes.
|
|
138
|
+
* Frameworks like Angular update classes asynchronously, often using
|
|
139
|
+
* requestAnimationFrame or promises. Using setTimeout ensures we check
|
|
140
|
+
* after all microtasks and animation frames have completed.
|
|
141
|
+
*/
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
const newIsInvalid = this.checkValidationState();
|
|
144
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
145
|
+
this.isInvalid = newIsInvalid;
|
|
146
|
+
index.forceUpdate(this);
|
|
147
|
+
}
|
|
148
|
+
}, 100);
|
|
132
149
|
};
|
|
133
150
|
this.onFocus = (ev) => {
|
|
134
151
|
this.hasFocus = true;
|
|
@@ -228,10 +245,42 @@ const Input = class {
|
|
|
228
245
|
componentWillLoad() {
|
|
229
246
|
this.inheritedAttributes = Object.assign(Object.assign({}, helpers.inheritAriaAttributes(this.el)), helpers.inheritAttributes(this.el, ['tabindex', 'title', 'data-form-type', 'dir']));
|
|
230
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Checks if the input is in an invalid state based on validation classes
|
|
250
|
+
*/
|
|
251
|
+
checkValidationState() {
|
|
252
|
+
// Check for both Ionic and Angular validation classes on the element itself
|
|
253
|
+
// Angular applies ng-touched/ng-invalid directly to the host element with ngModel
|
|
254
|
+
const hasIonTouched = this.el.classList.contains('ion-touched');
|
|
255
|
+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
|
|
256
|
+
const hasNgTouched = this.el.classList.contains('ng-touched');
|
|
257
|
+
const hasNgInvalid = this.el.classList.contains('ng-invalid');
|
|
258
|
+
// Return true if we have both touched and invalid states from either framework
|
|
259
|
+
const isTouched = hasIonTouched || hasNgTouched;
|
|
260
|
+
const isInvalid = hasIonInvalid || hasNgInvalid;
|
|
261
|
+
return isTouched && isInvalid;
|
|
262
|
+
}
|
|
231
263
|
connectedCallback() {
|
|
232
264
|
const { el } = this;
|
|
233
265
|
this.slotMutationController = input_utils.createSlotMutationController(el, ['label', 'start', 'end'], () => index.forceUpdate(this));
|
|
234
266
|
this.notchController = notchController.createNotchController(el, () => this.notchSpacerEl, () => this.labelSlot);
|
|
267
|
+
// Watch for class changes to update validation state
|
|
268
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
269
|
+
this.validationObserver = new MutationObserver(() => {
|
|
270
|
+
const newIsInvalid = this.checkValidationState();
|
|
271
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
272
|
+
this.isInvalid = newIsInvalid;
|
|
273
|
+
// Force a re-render to update aria-describedby immediately
|
|
274
|
+
index.forceUpdate(this);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
this.validationObserver.observe(el, {
|
|
278
|
+
attributes: true,
|
|
279
|
+
attributeFilter: ['class'],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// Always set initial state
|
|
283
|
+
this.isInvalid = this.checkValidationState();
|
|
235
284
|
this.debounceChanged();
|
|
236
285
|
{
|
|
237
286
|
document.dispatchEvent(new CustomEvent('ionInputDidLoad', {
|
|
@@ -268,6 +317,11 @@ const Input = class {
|
|
|
268
317
|
this.notchController.destroy();
|
|
269
318
|
this.notchController = undefined;
|
|
270
319
|
}
|
|
320
|
+
// Clean up validation observer to prevent memory leaks
|
|
321
|
+
if (this.validationObserver) {
|
|
322
|
+
this.validationObserver.disconnect();
|
|
323
|
+
this.validationObserver = undefined;
|
|
324
|
+
}
|
|
271
325
|
}
|
|
272
326
|
/**
|
|
273
327
|
* Sets focus on the native `input` in `ion-input`. Use this method instead of the global
|
|
@@ -369,15 +423,15 @@ const Input = class {
|
|
|
369
423
|
* Renders the helper text or error text values
|
|
370
424
|
*/
|
|
371
425
|
renderHintText() {
|
|
372
|
-
const { helperText, errorText, helperTextId, errorTextId } = this;
|
|
426
|
+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
|
373
427
|
return [
|
|
374
|
-
index.h("div", { id: helperTextId, class: "helper-text" }, helperText),
|
|
375
|
-
index.h("div", { id: errorTextId, class: "error-text" }, errorText),
|
|
428
|
+
helperText && (index.h("div", { id: helperTextId, class: "helper-text", "aria-live": "polite" }, helperText)),
|
|
429
|
+
errorText && (index.h("div", { id: errorTextId, class: "error-text", "aria-live": "assertive", "aria-atomic": "true", role: "alert", style: { display: isInvalid ? 'block' : 'none' } }, errorText)),
|
|
376
430
|
];
|
|
377
431
|
}
|
|
378
432
|
getHintTextID() {
|
|
379
|
-
const {
|
|
380
|
-
if (
|
|
433
|
+
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
|
|
434
|
+
if (isInvalid && errorText) {
|
|
381
435
|
return errorTextId;
|
|
382
436
|
}
|
|
383
437
|
if (helperText) {
|
|
@@ -490,7 +544,7 @@ const Input = class {
|
|
|
490
544
|
* TODO(FW-5592): Remove hasStartEndSlots condition
|
|
491
545
|
*/
|
|
492
546
|
const labelShouldFloat = labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
|
493
|
-
return (index.h(index.Host, { key: '
|
|
547
|
+
return (index.h(index.Host, { key: '1a141906096fc637d7de923edf6a9ea3a0168f5f', class: theme.createColorClasses(this.color, {
|
|
494
548
|
[mode]: true,
|
|
495
549
|
'has-value': hasValue,
|
|
496
550
|
'has-focus': hasFocus,
|
|
@@ -501,14 +555,14 @@ const Input = class {
|
|
|
501
555
|
'in-item': inItem,
|
|
502
556
|
'in-item-color': theme.hostContext('ion-item.ion-color', this.el),
|
|
503
557
|
'input-disabled': disabled,
|
|
504
|
-
}) }, index.h("label", { key: '
|
|
558
|
+
}) }, index.h("label", { key: '8371432bc0fd37099d3efc50705530965f27b9e1', class: "input-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), index.h("div", { key: '3d113da062bddf47b1bc67e67162c11fe4ee7f10', class: "native-wrapper", onClick: this.onLabelClick }, index.h("slot", { key: '3fe52ada4b91ebd21b21b1445b9d4009d47f8386', name: "start" }), index.h("input", Object.assign({ key: 'beda905e9d44797690149407ba814381ffd51d1d', class: "native-input", ref: (input) => (this.nativeInput = input), id: inputId, disabled: disabled, autoCapitalize: this.autocapitalize, autoComplete: this.autocomplete, autoCorrect: this.autocorrect, autoFocus: this.autofocus, enterKeyHint: this.enterkeyhint, inputMode: this.inputmode, min: this.min, max: this.max, minLength: this.minlength, maxLength: this.maxlength, multiple: this.multiple, name: this.name, pattern: this.pattern, placeholder: this.placeholder || '', readOnly: readonly, required: this.required, spellcheck: this.spellcheck, step: this.step, type: this.type, value: value, onInput: this.onInput, onChange: this.onChange, onBlur: this.onBlur, onFocus: this.onFocus, onKeyDown: this.onKeydown, onCompositionstart: this.onCompositionStart, onCompositionend: this.onCompositionEnd, "aria-describedby": this.getHintTextID(), "aria-invalid": this.isInvalid ? 'true' : undefined }, this.inheritedAttributes)), this.clearInput && !readonly && !disabled && (index.h("button", { key: 'bc8579a0acb727b348bcf277f296b2dac9cb583d', "aria-label": "reset", type: "button", class: "input-clear-icon", onPointerDown: (ev) => {
|
|
505
559
|
/**
|
|
506
560
|
* This prevents mobile browsers from
|
|
507
561
|
* blurring the input when the clear
|
|
508
562
|
* button is activated.
|
|
509
563
|
*/
|
|
510
564
|
ev.preventDefault();
|
|
511
|
-
}, onClick: this.clearTextInput }, index.h("ion-icon", { key: '
|
|
565
|
+
}, onClick: this.clearTextInput }, index.h("ion-icon", { key: '41a64ec6fce010eb225637998d93abd859a62be7', "aria-hidden": "true", icon: clearIconData }))), index.h("slot", { key: '9a7e6eba584748fc037aa1694d7e6e399de8b20b', name: "end" })), shouldRenderHighlight && index.h("div", { key: 'b828e6dc43b50f46727dda196de7ad2fecc3ef30', class: "input-highlight" })), this.renderBottomContent()));
|
|
512
566
|
}
|
|
513
567
|
get el() { return index.getElement(this); }
|
|
514
568
|
static get watchers() { return {
|
|
@@ -42,6 +42,10 @@ const Textarea = class {
|
|
|
42
42
|
* is applied in both cases.
|
|
43
43
|
*/
|
|
44
44
|
this.hasFocus = false;
|
|
45
|
+
/**
|
|
46
|
+
* Track validation state for proper aria-live announcements
|
|
47
|
+
*/
|
|
48
|
+
this.isInvalid = false;
|
|
45
49
|
/**
|
|
46
50
|
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
|
|
47
51
|
* Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
|
|
@@ -131,6 +135,19 @@ const Textarea = class {
|
|
|
131
135
|
}
|
|
132
136
|
this.didTextareaClearOnEdit = false;
|
|
133
137
|
this.ionBlur.emit(ev);
|
|
138
|
+
/**
|
|
139
|
+
* Check validation state after blur to handle framework-managed classes.
|
|
140
|
+
* Frameworks like Angular update classes asynchronously, often using
|
|
141
|
+
* requestAnimationFrame or promises. Using setTimeout ensures we check
|
|
142
|
+
* after all microtasks and animation frames have completed.
|
|
143
|
+
*/
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
const newIsInvalid = this.checkValidationState();
|
|
146
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
147
|
+
this.isInvalid = newIsInvalid;
|
|
148
|
+
index.forceUpdate(this);
|
|
149
|
+
}
|
|
150
|
+
}, 100);
|
|
134
151
|
};
|
|
135
152
|
this.onKeyDown = (ev) => {
|
|
136
153
|
this.checkClearOnEdit(ev);
|
|
@@ -188,10 +205,42 @@ const Textarea = class {
|
|
|
188
205
|
this.el.click();
|
|
189
206
|
}
|
|
190
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Checks if the textarea is in an invalid state based on validation classes
|
|
210
|
+
*/
|
|
211
|
+
checkValidationState() {
|
|
212
|
+
// Check for both Ionic and Angular validation classes on the element itself
|
|
213
|
+
// Angular applies ng-touched/ng-invalid directly to the host element with ngModel
|
|
214
|
+
const hasIonTouched = this.el.classList.contains('ion-touched');
|
|
215
|
+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
|
|
216
|
+
const hasNgTouched = this.el.classList.contains('ng-touched');
|
|
217
|
+
const hasNgInvalid = this.el.classList.contains('ng-invalid');
|
|
218
|
+
// Return true if we have both touched and invalid states from either framework
|
|
219
|
+
const isTouched = hasIonTouched || hasNgTouched;
|
|
220
|
+
const isInvalid = hasIonInvalid || hasNgInvalid;
|
|
221
|
+
return isTouched && isInvalid;
|
|
222
|
+
}
|
|
191
223
|
connectedCallback() {
|
|
192
224
|
const { el } = this;
|
|
193
225
|
this.slotMutationController = input_utils.createSlotMutationController(el, ['label', 'start', 'end'], () => index.forceUpdate(this));
|
|
194
226
|
this.notchController = notchController.createNotchController(el, () => this.notchSpacerEl, () => this.labelSlot);
|
|
227
|
+
// Watch for class changes to update validation state
|
|
228
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
229
|
+
this.validationObserver = new MutationObserver(() => {
|
|
230
|
+
const newIsInvalid = this.checkValidationState();
|
|
231
|
+
if (this.isInvalid !== newIsInvalid) {
|
|
232
|
+
this.isInvalid = newIsInvalid;
|
|
233
|
+
// Force a re-render to update aria-describedby immediately
|
|
234
|
+
index.forceUpdate(this);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
this.validationObserver.observe(el, {
|
|
238
|
+
attributes: true,
|
|
239
|
+
attributeFilter: ['class'],
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// Always set initial state
|
|
243
|
+
this.isInvalid = this.checkValidationState();
|
|
195
244
|
this.debounceChanged();
|
|
196
245
|
{
|
|
197
246
|
document.dispatchEvent(new CustomEvent('ionInputDidLoad', {
|
|
@@ -213,6 +262,11 @@ const Textarea = class {
|
|
|
213
262
|
this.notchController.destroy();
|
|
214
263
|
this.notchController = undefined;
|
|
215
264
|
}
|
|
265
|
+
// Clean up validation observer to prevent memory leaks
|
|
266
|
+
if (this.validationObserver) {
|
|
267
|
+
this.validationObserver.disconnect();
|
|
268
|
+
this.validationObserver = undefined;
|
|
269
|
+
}
|
|
216
270
|
}
|
|
217
271
|
componentWillLoad() {
|
|
218
272
|
this.inheritedAttributes = Object.assign(Object.assign({}, helpers.inheritAriaAttributes(this.el)), helpers.inheritAttributes(this.el, ['data-form-type', 'title', 'tabindex', 'dir']));
|
|
@@ -383,15 +437,15 @@ const Textarea = class {
|
|
|
383
437
|
* Renders the helper text or error text values
|
|
384
438
|
*/
|
|
385
439
|
renderHintText() {
|
|
386
|
-
const { helperText, errorText, helperTextId, errorTextId } = this;
|
|
440
|
+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
|
387
441
|
return [
|
|
388
|
-
index.h("div", { id: helperTextId, class: "helper-text" }, helperText),
|
|
389
|
-
index.h("div", { id: errorTextId, class: "error-text" }, errorText),
|
|
442
|
+
helperText && (index.h("div", { id: helperTextId, class: "helper-text", "aria-live": "polite" }, helperText)),
|
|
443
|
+
errorText && (index.h("div", { id: errorTextId, class: "error-text", "aria-live": "assertive", "aria-atomic": "true", role: "alert", style: { display: isInvalid ? 'block' : 'none' } }, errorText)),
|
|
390
444
|
];
|
|
391
445
|
}
|
|
392
446
|
getHintTextID() {
|
|
393
|
-
const {
|
|
394
|
-
if (
|
|
447
|
+
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
|
|
448
|
+
if (isInvalid && errorText) {
|
|
395
449
|
return errorTextId;
|
|
396
450
|
}
|
|
397
451
|
if (helperText) {
|
|
@@ -450,7 +504,7 @@ const Textarea = class {
|
|
|
450
504
|
* TODO(FW-5592): Remove hasStartEndSlots condition
|
|
451
505
|
*/
|
|
452
506
|
const labelShouldFloat = labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
|
453
|
-
return (index.h(index.Host, { key: '
|
|
507
|
+
return (index.h(index.Host, { key: '803c7648de15b8569c3df01692548018cc660510', class: theme.createColorClasses(this.color, {
|
|
454
508
|
[mode]: true,
|
|
455
509
|
'has-value': hasValue,
|
|
456
510
|
'has-focus': hasFocus,
|
|
@@ -459,7 +513,7 @@ const Textarea = class {
|
|
|
459
513
|
[`textarea-shape-${shape}`]: shape !== undefined,
|
|
460
514
|
[`textarea-label-placement-${labelPlacement}`]: true,
|
|
461
515
|
'textarea-disabled': disabled,
|
|
462
|
-
}) }, index.h("label", { key: '
|
|
516
|
+
}) }, index.h("label", { key: '0e3a4e5fc809437abc6780eb7f99a0c770bb9f94', class: "textarea-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), index.h("div", { key: 'ff8d71e2d53daf4999338232d3b0a597627a3276', class: "textarea-wrapper-inner" }, index.h("div", { key: '313f5a8dc8b62e8ae6a0638a7e201b2594c180eb', class: "start-slot-wrapper" }, index.h("slot", { key: '32db088d41398be935f7060138e4d80a832fea85', name: "start" })), index.h("div", { key: '24b63ff40ffcae8816f24d9c8a6ec2e7848e0e0b', class: "native-wrapper", ref: (el) => (this.textareaWrapper = el) }, index.h("textarea", Object.assign({ key: '2a4f918351f87d444d8922254eefa0941a6ca238', class: "native-textarea", ref: (el) => (this.nativeInput = el), id: inputId, disabled: disabled, autoCapitalize: this.autocapitalize, autoFocus: this.autofocus, enterKeyHint: this.enterkeyhint, inputMode: this.inputmode, minLength: this.minlength, maxLength: this.maxlength, name: this.name, placeholder: this.placeholder || '', readOnly: this.readonly, required: this.required, spellcheck: this.spellcheck, cols: this.cols, rows: this.rows, wrap: this.wrap, onInput: this.onInput, onChange: this.onChange, onBlur: this.onBlur, onFocus: this.onFocus, onKeyDown: this.onKeyDown, "aria-describedby": this.getHintTextID(), "aria-invalid": this.isInvalid ? 'true' : undefined }, this.inheritedAttributes), value)), index.h("div", { key: '2c32636d2d13560de0a54507e1668eb9f4bcc2cf', class: "end-slot-wrapper" }, index.h("slot", { key: 'ccc3549246fd9d2e309958767a7a24e1be8545fd', name: "end" }))), shouldRenderHighlight && index.h("div", { key: 'f4719399ec530ce295b19817688b8d39d84b6b09', class: "textarea-highlight" })), this.renderBottomContent()));
|
|
463
517
|
}
|
|
464
518
|
get el() { return index.getElement(this); }
|
|
465
519
|
static get watchers() { return {
|