11import type { ComponentInterface , EventEmitter } from '@stencil/core' ;
2- import { Component , Element , Event , Host , Prop , State , Watch , h } from '@stencil/core' ;
2+ import { Build , Component , Element , Event , Host , Prop , State , Watch , h } from '@stencil/core' ;
3+ import { checkInvalidState } from '@utils/forms' ;
34import { renderHiddenInput , inheritAriaAttributes } from '@utils/helpers' ;
45import type { Attributes } from '@utils/helpers' ;
56import { hapticSelection } from '@utils/native/haptic' ;
@@ -44,11 +45,19 @@ export class Toggle implements ComponentInterface {
4445 private inheritedAttributes : Attributes = { } ;
4546 private toggleTrack ?: HTMLElement ;
4647 private didLoad = false ;
48+ private validationObserver ?: MutationObserver ;
4749
4850 @Element ( ) el ! : HTMLIonToggleElement ;
4951
5052 @State ( ) activated = false ;
5153
54+ /**
55+ * Track validation state for proper aria-live announcements.
56+ */
57+ @State ( ) isInvalid = false ;
58+
59+ @State ( ) private hintTextID ?: string ;
60+
5261 /**
5362 * The color to use from your application's color palette.
5463 * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@@ -168,15 +177,56 @@ export class Toggle implements ComponentInterface {
168177 }
169178
170179 async connectedCallback ( ) {
180+ const { didLoad, el } = this ;
181+
171182 /**
172183 * If we have not yet rendered
173184 * ion-toggle, then toggleTrack is not defined.
174185 * But if we are moving ion-toggle via appendChild,
175186 * then toggleTrack will be defined.
176187 */
177- if ( this . didLoad ) {
188+ if ( didLoad ) {
178189 this . setupGesture ( ) ;
179190 }
191+
192+ // Watch for class changes to update validation state.
193+ if ( Build . isBrowser && typeof MutationObserver !== 'undefined' ) {
194+ this . validationObserver = new MutationObserver ( ( ) => {
195+ const newIsInvalid = checkInvalidState ( el ) ;
196+ if ( this . isInvalid !== newIsInvalid ) {
197+ this . isInvalid = newIsInvalid ;
198+ /**
199+ * Screen readers tend to announce changes
200+ * to `aria-describedby` when the attribute
201+ * is changed during a blur event for a
202+ * native form control.
203+ * However, the announcement can be spotty
204+ * when using a non-native form control
205+ * and `forceUpdate()`.
206+ * This is due to `forceUpdate()` internally
207+ * rescheduling the DOM update to a lower
208+ * priority queue regardless if it's called
209+ * inside a Promise or not, thus causing
210+ * the screen reader to potentially miss the
211+ * change.
212+ * By using a State variable inside a Promise,
213+ * it guarantees a re-render immediately at
214+ * a higher priority.
215+ */
216+ Promise . resolve ( ) . then ( ( ) => {
217+ this . hintTextID = this . getHintTextID ( ) ;
218+ } ) ;
219+ }
220+ } ) ;
221+
222+ this . validationObserver . observe ( el , {
223+ attributes : true ,
224+ attributeFilter : [ 'class' ] ,
225+ } ) ;
226+ }
227+
228+ // Always set initial state
229+ this . isInvalid = checkInvalidState ( el ) ;
180230 }
181231
182232 componentDidLoad ( ) {
@@ -207,6 +257,12 @@ export class Toggle implements ComponentInterface {
207257 this . gesture . destroy ( ) ;
208258 this . gesture = undefined ;
209259 }
260+
261+ // Clean up validation observer to prevent memory leaks.
262+ if ( this . validationObserver ) {
263+ this . validationObserver . disconnect ( ) ;
264+ this . validationObserver = undefined ;
265+ }
210266 }
211267
212268 componentWillLoad ( ) {
@@ -336,9 +392,9 @@ export class Toggle implements ComponentInterface {
336392 }
337393
338394 private getHintTextID ( ) : string | undefined {
339- const { el , helperText, errorText, helperTextId, errorTextId } = this ;
395+ const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this ;
340396
341- if ( el . classList . contains ( 'ion-touched' ) && el . classList . contains ( 'ion-invalid' ) && errorText ) {
397+ if ( isInvalid && errorText ) {
342398 return errorTextId ;
343399 }
344400
@@ -354,7 +410,7 @@ export class Toggle implements ComponentInterface {
354410 * This element should only be rendered if hint text is set.
355411 */
356412 private renderHintText ( ) {
357- const { helperText, errorText, helperTextId, errorTextId } = this ;
413+ const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this ;
358414
359415 /**
360416 * undefined and empty string values should
@@ -367,11 +423,11 @@ export class Toggle implements ComponentInterface {
367423
368424 return (
369425 < div class = "toggle-bottom" >
370- < div id = { helperTextId } class = "helper-text" part = "supporting-text helper-text" >
371- { helperText }
426+ < div id = { helperTextId } class = "helper-text" part = "supporting-text helper-text" aria-live = "polite" >
427+ { ! isInvalid ? helperText : null }
372428 </ div >
373- < div id = { errorTextId } class = "error-text" part = "supporting-text error-text" >
374- { errorText }
429+ < div id = { errorTextId } class = "error-text" part = "supporting-text error-text" role = "alert" >
430+ { isInvalid ? errorText : null }
375431 </ div >
376432 </ div >
377433 ) ;
@@ -385,7 +441,6 @@ export class Toggle implements ComponentInterface {
385441 color,
386442 disabled,
387443 el,
388- errorTextId,
389444 hasLabel,
390445 inheritedAttributes,
391446 inputId,
@@ -405,12 +460,13 @@ export class Toggle implements ComponentInterface {
405460 < Host
406461 role = "switch"
407462 aria-checked = { `${ checked } ` }
408- aria-describedby = { this . getHintTextID ( ) }
409- aria-invalid = { this . getHintTextID ( ) === errorTextId }
463+ aria-describedby = { this . hintTextID }
464+ aria-invalid = { this . isInvalid ? 'true' : undefined }
410465 onClick = { this . onClick }
411466 aria-labelledby = { hasLabel ? inputLabelId : null }
412467 aria-label = { inheritedAttributes [ 'aria-label' ] || null }
413468 aria-disabled = { disabled ? 'true' : null }
469+ aria-required = { required ? 'true' : undefined }
414470 tabindex = { disabled ? undefined : 0 }
415471 onKeyDown = { this . onKeyDown }
416472 onFocus = { this . onFocus }
0 commit comments