11import type { ComponentInterface , EventEmitter } from '@stencil/core' ;
2- import { Component , Element , Event , Host , Method , Prop , State , Watch , h , forceUpdate } from '@stencil/core' ;
2+ import { Build , Component , Element , Event , Host , Method , Prop , State , Watch , h , forceUpdate } from '@stencil/core' ;
33import type { NotchController } from '@utils/forms' ;
44import { compareOptions , createNotchController , isOptionSelected } from '@utils/forms' ;
55import { focusVisibleElement , renderHiddenInput , inheritAttributes } from '@utils/helpers' ;
@@ -64,6 +64,7 @@ export class Select implements ComponentInterface {
6464 private inheritedAttributes : Attributes = { } ;
6565 private nativeWrapperEl : HTMLElement | undefined ;
6666 private notchSpacerEl : HTMLElement | undefined ;
67+ private validationObserver ?: MutationObserver ;
6768
6869 private notchController ?: NotchController ;
6970
@@ -81,6 +82,13 @@ export class Select implements ComponentInterface {
8182 */
8283 @State ( ) hasFocus = false ;
8384
85+ /**
86+ * Track validation state for proper aria-live announcements.
87+ */
88+ @State ( ) isInvalid = false ;
89+
90+ @State ( ) private hintTextID ?: string ;
91+
8492 /**
8593 * The text to display on the cancel button.
8694 */
@@ -298,10 +306,49 @@ export class Select implements ComponentInterface {
298306 */
299307 forceUpdate ( this ) ;
300308 } ) ;
309+
310+ // Watch for class changes to update validation state.
311+ if ( Build . isBrowser && typeof MutationObserver !== 'undefined' ) {
312+ this . validationObserver = new MutationObserver ( ( ) => {
313+ const newIsInvalid = this . checkInvalidState ( ) ;
314+ if ( this . isInvalid !== newIsInvalid ) {
315+ this . isInvalid = newIsInvalid ;
316+ /**
317+ * Screen readers tend to announce changes
318+ * to `aria-describedby` when the attribute
319+ * is changed during a blur event for a
320+ * native form control.
321+ * However, the announcement can be spotty
322+ * when using a non-native form control
323+ * and `forceUpdate()`.
324+ * This is due to `forceUpdate()` not being
325+ * high priority enough to guarantee
326+ * the DOM is updated before the screen reader
327+ * announces the attribute change.
328+ * By using a promise, it makes sure to
329+ * announce the change before the next frame
330+ * since promises are high priority.
331+ */
332+ Promise . resolve ( ) . then ( ( ) => {
333+ this . hintTextID = this . getHintTextID ( ) ;
334+ } ) ;
335+ }
336+ } ) ;
337+
338+ this . validationObserver . observe ( el , {
339+ attributes : true ,
340+ attributeFilter : [ 'class' ] ,
341+ } ) ;
342+ }
343+
344+ // Always set initial state
345+ this . isInvalid = this . checkInvalidState ( ) ;
301346 }
302347
303348 componentWillLoad ( ) {
304349 this . inheritedAttributes = inheritAttributes ( this . el , [ 'aria-label' ] ) ;
350+
351+ this . hintTextID = this . getHintTextID ( ) ;
305352 }
306353
307354 componentDidLoad ( ) {
@@ -328,6 +375,12 @@ export class Select implements ComponentInterface {
328375 this . notchController . destroy ( ) ;
329376 this . notchController = undefined ;
330377 }
378+
379+ // Clean up validation observer to prevent memory leaks.
380+ if ( this . validationObserver ) {
381+ this . validationObserver . disconnect ( ) ;
382+ this . validationObserver = undefined ;
383+ }
331384 }
332385
333386 /**
@@ -1056,8 +1109,8 @@ export class Select implements ComponentInterface {
10561109 aria-label = { this . ariaLabel }
10571110 aria-haspopup = "dialog"
10581111 aria-expanded = { `${ isExpanded } ` }
1059- aria-describedby = { this . getHintTextID ( ) }
1060- aria-invalid = { this . getHintTextID ( ) === this . errorTextId }
1112+ aria-describedby = { this . hintTextID }
1113+ aria-invalid = { this . isInvalid ? 'true' : undefined }
10611114 aria-required = { `${ required } ` }
10621115 onFocus = { this . onFocus }
10631116 onBlur = { this . onBlur }
@@ -1067,9 +1120,9 @@ export class Select implements ComponentInterface {
10671120 }
10681121
10691122 private getHintTextID ( ) : string | undefined {
1070- const { el , helperText, errorText, helperTextId, errorTextId } = this ;
1123+ const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this ;
10711124
1072- if ( el . classList . contains ( 'ion-touched' ) && el . classList . contains ( 'ion-invalid' ) && errorText ) {
1125+ if ( isInvalid && errorText ) {
10731126 return errorTextId ;
10741127 }
10751128
@@ -1084,14 +1137,14 @@ export class Select implements ComponentInterface {
10841137 * Renders the helper text or error text values
10851138 */
10861139 private renderHintText ( ) {
1087- const { helperText, errorText, helperTextId, errorTextId } = this ;
1140+ const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this ;
10881141
10891142 return [
1090- < div id = { helperTextId } class = "helper-text" part = "supporting-text helper-text" >
1091- { helperText }
1143+ < div id = { helperTextId } class = "helper-text" part = "supporting-text helper-text" aria-live = "polite" >
1144+ { ! isInvalid ? helperText : null }
10921145 </ div > ,
1093- < div id = { errorTextId } class = "error-text" part = "supporting-text error-text" >
1094- { errorText }
1146+ < div id = { errorTextId } class = "error-text" part = "supporting-text error-text" role = "alert" >
1147+ { isInvalid ? errorText : null }
10951148 </ div > ,
10961149 ] ;
10971150 }
@@ -1115,6 +1168,17 @@ export class Select implements ComponentInterface {
11151168 return < div class = "select-bottom" > { this . renderHintText ( ) } </ div > ;
11161169 }
11171170
1171+ /**
1172+ * Checks if the input is in an invalid state based
1173+ * on Ionic validation classes.
1174+ */
1175+ private checkInvalidState ( ) : boolean {
1176+ const hasIonTouched = this . el . classList . contains ( 'ion-touched' ) ;
1177+ const hasIonInvalid = this . el . classList . contains ( 'ion-invalid' ) ;
1178+
1179+ return hasIonTouched && hasIonInvalid ;
1180+ }
1181+
11181182 render ( ) {
11191183 const {
11201184 disabled,
0 commit comments