@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.
@@ -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 { el, helperText, errorText, helperTextId, errorTextId } = this;
379
- if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
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: '41b2526627e7d2773a80f011b123284203a71ca0', class: createColorClasses(this.color, {
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: '9ab078363e32528102b441ad1791d83f86fdcbdc', class: "input-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), h("div", { key: 'e34b594980ec62e4c618e827fadf7669a39ad0d8', class: "native-wrapper", onClick: this.onLabelClick }, h("slot", { key: '12dc04ead5502e9e5736240e918bf9331bf7b5d9', name: "start" }), h("input", Object.assign({ key: 'df356eb4ced23109b2c0242f36dc043aba8782d6', 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.getHintTextID() === this.errorTextId }, this.inheritedAttributes)), this.clearInput && !readonly && !disabled && (h("button", { key: 'f79f68cabcd4ea99419331174a377827db0c0741', "aria-label": "reset", type: "button", class: "input-clear-icon", onPointerDown: (ev) => {
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: '237ec07ec2e10f08818a332bb596578c2c49f770', "aria-hidden": "true", icon: clearIconData }))), h("slot", { key: '1f0a3624aa3e8dc3c307a6762230ab698768a5e5', name: "end" })), shouldRenderHighlight && h("div", { key: '8a8cbb82695a722a0010b53dd0b1f1f97534a20b', class: "input-highlight" })), this.renderBottomContent()));
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 { el, helperText, errorText, helperTextId, errorTextId } = this;
392
- if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
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: 'd9f2ede0107987fc42c99e310cd2336bad5a5755', class: createColorClasses(this.color, {
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: '9de598b95237462bb3bccffaefe83afbb43554b8', class: "textarea-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), h("div", { key: 'e33c426c6541d723ccc246bb404c03687726ff83', class: "textarea-wrapper-inner" }, h("div", { key: '521e11af9d54d281b0a2b1c25bcfc6f742c18296', class: "start-slot-wrapper" }, h("slot", { key: '515523f6ca3ce0e5dd08f3275c21a190fb1ca177', name: "start" })), h("div", { key: '916e01e00de8400ae00ef06bc1fb62d8be2eee08', class: "native-wrapper", ref: (el) => (this.textareaWrapper = el) }, h("textarea", Object.assign({ key: '810271e6532d90e27dab1fcb26546113c1ce9cb0', 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.getHintTextID() === this.errorTextId }, this.inheritedAttributes), value)), h("div", { key: '80aca9ea9546dca9d38efd291a6b0be384bb6978', class: "end-slot-wrapper" }, h("slot", { key: '407fab16c66a9f4a542369bfecc0d9afa0065977', name: "end" }))), shouldRenderHighlight && h("div", { key: 'f00523a6698fac8a1996e04303487bef01d10f25', class: "textarea-highlight" })), this.renderBottomContent()));
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 { el, helperText, errorText, helperTextId, errorTextId } = this;
380
- if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
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: '41b2526627e7d2773a80f011b123284203a71ca0', class: theme.createColorClasses(this.color, {
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: '9ab078363e32528102b441ad1791d83f86fdcbdc', class: "input-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), index.h("div", { key: 'e34b594980ec62e4c618e827fadf7669a39ad0d8', class: "native-wrapper", onClick: this.onLabelClick }, index.h("slot", { key: '12dc04ead5502e9e5736240e918bf9331bf7b5d9', name: "start" }), index.h("input", Object.assign({ key: 'df356eb4ced23109b2c0242f36dc043aba8782d6', 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.getHintTextID() === this.errorTextId }, this.inheritedAttributes)), this.clearInput && !readonly && !disabled && (index.h("button", { key: 'f79f68cabcd4ea99419331174a377827db0c0741', "aria-label": "reset", type: "button", class: "input-clear-icon", onPointerDown: (ev) => {
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: '237ec07ec2e10f08818a332bb596578c2c49f770', "aria-hidden": "true", icon: clearIconData }))), index.h("slot", { key: '1f0a3624aa3e8dc3c307a6762230ab698768a5e5', name: "end" })), shouldRenderHighlight && index.h("div", { key: '8a8cbb82695a722a0010b53dd0b1f1f97534a20b', class: "input-highlight" })), this.renderBottomContent()));
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 { el, helperText, errorText, helperTextId, errorTextId } = this;
394
- if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
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: 'd9f2ede0107987fc42c99e310cd2336bad5a5755', class: theme.createColorClasses(this.color, {
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: '9de598b95237462bb3bccffaefe83afbb43554b8', class: "textarea-wrapper", htmlFor: inputId, onClick: this.onLabelClick }, this.renderLabelContainer(), index.h("div", { key: 'e33c426c6541d723ccc246bb404c03687726ff83', class: "textarea-wrapper-inner" }, index.h("div", { key: '521e11af9d54d281b0a2b1c25bcfc6f742c18296', class: "start-slot-wrapper" }, index.h("slot", { key: '515523f6ca3ce0e5dd08f3275c21a190fb1ca177', name: "start" })), index.h("div", { key: '916e01e00de8400ae00ef06bc1fb62d8be2eee08', class: "native-wrapper", ref: (el) => (this.textareaWrapper = el) }, index.h("textarea", Object.assign({ key: '810271e6532d90e27dab1fcb26546113c1ce9cb0', 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.getHintTextID() === this.errorTextId }, this.inheritedAttributes), value)), index.h("div", { key: '80aca9ea9546dca9d38efd291a6b0be384bb6978', class: "end-slot-wrapper" }, index.h("slot", { key: '407fab16c66a9f4a542369bfecc0d9afa0065977', name: "end" }))), shouldRenderHighlight && index.h("div", { key: 'f00523a6698fac8a1996e04303487bef01d10f25', class: "textarea-highlight" })), this.renderBottomContent()));
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 {