11import type { ComponentInterface , EventEmitter } from '@stencil/core' ;
2- import { Component , Element , Event , Host , Method , Prop , State , h , forceUpdate } from '@stencil/core' ;
2+ import { Component , Element , Event , Host , Method , Prop , State , h , forceUpdate , Build } from '@stencil/core' ;
3+ import { checkInvalidState } from '@utils/forms' ;
34import type { Attributes } from '@utils/helpers' ;
45import { inheritAriaAttributes , renderHiddenInput } from '@utils/helpers' ;
56import { createColorClasses , hostContext } from '@utils/theme' ;
@@ -35,6 +36,7 @@ export class Checkbox implements ComponentInterface {
3536 private helperTextId = `${ this . inputId } -helper-text` ;
3637 private errorTextId = `${ this . inputId } -error-text` ;
3738 private inheritedAttributes : Attributes = { } ;
39+ private validationObserver ?: MutationObserver ;
3840
3941 @Element ( ) el ! : HTMLIonCheckboxElement ;
4042
@@ -125,6 +127,8 @@ export class Checkbox implements ComponentInterface {
125127 */
126128 @State ( ) isInvalid = false ;
127129
130+ @State ( ) private hintTextID ?: string ;
131+
128132 /**
129133 * Emitted when the checked property has changed as a result of a user action such as a click.
130134 *
@@ -143,7 +147,45 @@ export class Checkbox implements ComponentInterface {
143147 @Event ( ) ionBlur ! : EventEmitter < void > ;
144148
145149 connectedCallback ( ) {
146- // Always set initial state.
150+ const { el } = this ;
151+
152+ // Watch for class changes to update validation state.
153+ if ( Build . isBrowser && typeof MutationObserver !== 'undefined' ) {
154+ this . validationObserver = new MutationObserver ( ( ) => {
155+ const newIsInvalid = checkInvalidState ( el ) ;
156+ if ( this . isInvalid !== newIsInvalid ) {
157+ this . isInvalid = newIsInvalid ;
158+ /**
159+ * Screen readers tend to announce changes
160+ * to `aria-describedby` when the attribute
161+ * is changed during a blur event for a
162+ * native form control.
163+ * However, the announcement can be spotty
164+ * when using a non-native form control
165+ * and `forceUpdate()`.
166+ * This is due to `forceUpdate()` internally
167+ * rescheduling the DOM update to a lower
168+ * priority queue regardless if it's called
169+ * inside a Promise or not, thus causing
170+ * the screen reader to potentially miss the
171+ * change.
172+ * By using a State variable inside a Promise,
173+ * it guarantees a re-render immediately at
174+ * a higher priority.
175+ */
176+ Promise . resolve ( ) . then ( ( ) => {
177+ this . hintTextID = this . getHintTextID ( ) ;
178+ } ) ;
179+ }
180+ } ) ;
181+
182+ this . validationObserver . observe ( el , {
183+ attributes : true ,
184+ attributeFilter : [ 'class' ] ,
185+ } ) ;
186+ }
187+
188+ // Always set initial state
147189 this . isInvalid = this . checkInvalidState ( ) ;
148190 }
149191
@@ -153,6 +195,14 @@ export class Checkbox implements ComponentInterface {
153195 } ;
154196 }
155197
198+ disconnectedCallback ( ) {
199+ // Clean up validation observer to prevent memory leaks.
200+ if ( this . validationObserver ) {
201+ this . validationObserver . disconnect ( ) ;
202+ this . validationObserver = undefined ;
203+ }
204+ }
205+
156206 /** @internal */
157207 @Method ( )
158208 async setFocus ( ) {
@@ -301,7 +351,7 @@ export class Checkbox implements ComponentInterface {
301351 < Host
302352 role = "checkbox"
303353 aria-checked = { indeterminate ? 'mixed' : `${ checked } ` }
304- aria-describedby = { this . getHintTextID ( ) }
354+ aria-describedby = { this . hintTextID }
305355 aria-invalid = { this . isInvalid ? 'true' : undefined }
306356 aria-labelledby = { hasLabelContent ? this . inputLabelId : null }
307357 aria-label = { inheritedAttributes [ 'aria-label' ] || null }
0 commit comments