11import {
2- Attribute ,
32 ChangeDetectionStrategy ,
43 Component ,
4+ computed ,
5+ effect ,
56 ElementRef ,
6- HostBinding ,
7+ inject ,
8+ input ,
79 Input ,
810 OnChanges ,
911 OnDestroy ,
1012 OnInit ,
13+ Renderer2 ,
14+ signal ,
1115 ViewEncapsulation
1216} from '@angular/core' ;
1317import { FormStates } from '@fundamental-ngx/cdk/forms' ;
14- import { CssClassBuilder , Nullable , applyCssClass } from '@fundamental-ngx/cdk/utils' ;
18+ import { applyCssClass , CssClassBuilder , Nullable } from '@fundamental-ngx/cdk/utils' ;
1519import { ContentDensityObserver , contentDensityObserverProviders } from '@fundamental-ngx/core/content-density' ;
20+ import { ValueStateAriaMessageService } from '@fundamental-ngx/core/shared' ;
1621import { Subscription } from 'rxjs' ;
1722import { FormItemControl , registerFormItemControl } from '../form-item-control/form-item-control' ;
1823
19- /**
20- * Directive intended for use on form controls.
21- *
22- * ```html
23- * <input type="text" fd-form-control />
24- * ```
25- */
24+ let formControlId = 0 ;
25+
2626@Component ( {
2727 // eslint-disable-next-line @angular-eslint/component-selector
2828 selector : 'input[fd-form-control], textarea[fd-form-control]' ,
29- template : ` <ng-content></ng-content>` ,
29+ template : `<ng-content></ng-content>` ,
3030 styleUrl : './form-control.component.scss' ,
3131 encapsulation : ViewEncapsulation . None ,
3232 changeDetection : ChangeDetectionStrategy . OnPush ,
33- providers : [ registerFormItemControl ( FormControlComponent ) , contentDensityObserverProviders ( ) ]
33+ providers : [ registerFormItemControl ( FormControlComponent ) , contentDensityObserverProviders ( ) ] ,
34+ host : {
35+ '[attr.type]' : 'type()' ,
36+ '[attr.aria-label]' : 'ariaLabel' ,
37+ '[attr.aria-labelledby]' : 'ariaLabelledBy' ,
38+ '[attr.aria-describedby]' : 'combinedAriaDescribedBy()'
39+ }
3440} )
3541export class FormControlComponent implements CssClassBuilder , OnInit , OnChanges , OnDestroy , FormItemControl {
42+ /** aria-label for form-control. */
43+ @Input ( )
44+ ariaLabelledBy : Nullable < string > ;
45+
46+ /** aria-label for form-control. */
47+ @Input ( )
48+ ariaLabel : Nullable < string > ;
49+
3650 /**
3751 * The state of the form control - applies css classes.
3852 * Can be `success`, `error`, `warning`, `information` or blank for default.
3953 */
40- @Input ( )
41- state : FormStates | null = null ;
54+ state = input < FormStates | null > ( null ) ;
4255
4356 /** Type of the form control. */
44- @HostBinding ( 'attr.type' )
45- @Input ( )
46- type : string ;
57+ type = input < string > ( ) ;
4758
48- /** user's custom classes */
49- @Input ( )
50- class : string ;
59+ /** User's custom classes */
60+ class = input < string > ( ) ;
5161
52- /** aria-label for form-control. */
53- @Input ( )
54- ariaLabel : Nullable < string > ;
62+ /** Default ARIA message text for the "success" value state. */
63+ valueStateSuccessMessage = input < string > ( inject ( ValueStateAriaMessageService ) . success ) ;
5564
56- /** aria-label for form-control. */
57- @Input ( )
58- ariaLabelledBy : Nullable < string > ;
65+ /** Default ARIA message text for the "information" value state. */
66+ valueStateInformationMessage = input < string > ( inject ( ValueStateAriaMessageService ) . information ) ;
67+
68+ /** Default ARIA message text for the "warning" value state. */
69+ valueStateWarningMessage = input < string > ( inject ( ValueStateAriaMessageService ) . warning ) ;
70+
71+ /** Default ARIA message text for the "error" value state. */
72+ valueStateErrorMessage = input < string > ( inject ( ValueStateAriaMessageService ) . error ) ;
73+
74+ /**
75+ * @hidden
76+ * Stores the value of the `aria-describedby` attribute set by the enclosing Form Item component.
77+ */
78+ formItemAriaDescribedBy = signal < Nullable < string > > ( null ) ;
79+
80+ /**
81+ * @hidden
82+ * Computes the full list of element IDs that should be referenced by `aria-describedby`.
83+ *
84+ * The final string may include:
85+ * - The generated ID of the visually hidden span for value state messages (success, error, warning, info).
86+ * - Any user-provided IDs from a native `aria-describedby` attribute.
87+ * - Any user-provided IDs from a native `aria-errormessage` attribute.
88+ * - Any IDs set by the parent Form Item via `formItemAriaDescribedBy`.
89+ *
90+ * All IDs are concatenated with spaces, and `null` is returned if none exist.
91+ */
92+
93+ combinedAriaDescribedBy = computed ( ( ) => {
94+ const userAriaDescribedByID = this . _userAriaDescribedBy ( ) ;
95+ const userAriaErrorMessageID = this . _userAriaErrorMessage ( ) ;
96+ const valueStateId = this . state ( ) ? this . _valueStateMessageId : null ;
97+
98+ // Include formItemAriaDescribedBy only if no user-provided IDs exist
99+ const formItemAriaDescribedById =
100+ ! userAriaDescribedByID && ! userAriaErrorMessageID ? this . formItemAriaDescribedBy ( ) : null ;
101+
102+ return (
103+ [ valueStateId , userAriaDescribedByID , userAriaErrorMessageID , formItemAriaDescribedById ]
104+ . filter ( Boolean )
105+ . join ( ' ' ) || null
106+ ) ;
107+ } ) ;
59108
60109 /** @hidden */
61- @HostBinding ( 'attr.aria-label' )
62- private get ariaLabelBinding ( ) : string {
63- return this . ariaLabelAttr || this . ariaLabel || '' ;
64- }
110+ public elementRef = inject ( ElementRef ) ;
65111
66112 /** @hidden */
67- @ HostBinding ( 'attr.aria-labelledby' )
68- private get ariaLabelledByBinding ( ) : string {
69- return this . ariaLabelledByAttr || this . ariaLabelledBy || '' ;
70- }
113+ private contentDensityObserver = inject ( ContentDensityObserver ) ;
114+
115+ /** @hidden */
116+ private renderer = inject ( Renderer2 ) ;
71117
72118 /** @hidden */
73119 private _subscriptions = new Subscription ( ) ;
74120
121+ /**
122+ * @hidden
123+ * Unique ID assigned to the hidden value state message <span>,
124+ * used to link it with `aria-describedby`.
125+ */
126+ private _valueStateMessageId = `fd-form-control-value-state-${ ++ formControlId } ` ;
127+
128+ /**
129+ * @hidden
130+ * Reference to the hidden <span> element that holds the value state message for screen readers.
131+ * Created dynamically when the control has a state.
132+ */
133+ private _valueStateSpan : HTMLElement | null = null ;
134+
135+ /**
136+ * @hidden
137+ * Stores the value of the user-defined `aria-describedby` attribute (if present on the host element).
138+ */
139+ private _userAriaDescribedBy = signal < Nullable < string > > ( null ) ;
140+
141+ /**
142+ * @hidden
143+ * Stores the value of the user-defined `aria-errormessage` attribute (if present on the host element).
144+ */
145+ private _userAriaErrorMessage = signal < Nullable < string > > ( null ) ;
146+
147+ /** @hidden */
148+ private _valueStateMessages = {
149+ success : this . valueStateSuccessMessage ,
150+ information : this . valueStateInformationMessage ,
151+ warning : this . valueStateWarningMessage ,
152+ error : this . valueStateErrorMessage
153+ } as const ;
154+
155+ /** @hidden */
156+ private _currentValueStateMessage = computed ( ( ) => {
157+ const st = this . state ( ) ;
158+ const signalMsg = st ? this . _valueStateMessages [ st ] : null ;
159+ return signalMsg ? signalMsg ( ) : '' ;
160+ } ) ;
161+
75162 /** @hidden */
76- constructor (
77- public elementRef : ElementRef < HTMLInputElement | HTMLTextAreaElement > ,
78- _contentDensityObserver : ContentDensityObserver ,
79- @Attribute ( 'aria-label' ) private ariaLabelAttr : string ,
80- @Attribute ( 'aria-labelledby' ) private ariaLabelledByAttr : string
81- ) {
82- _contentDensityObserver . subscribe ( ) ;
163+ constructor ( ) {
164+ this . _subscriptions . add ( this . contentDensityObserver . subscribe ( ) ) ;
165+
166+ // Update the hidden span’s text whenever the value state message changes
167+ effect ( ( ) => {
168+ if ( ! this . _valueStateSpan ) {
169+ return ;
170+ }
171+
172+ const msg = this . _currentValueStateMessage ( ) ;
173+ this . _valueStateSpan . textContent = msg ?? '' ;
174+ } ) ;
83175 }
84176
85177 /**
@@ -92,15 +184,38 @@ export class FormControlComponent implements CssClassBuilder, OnInit, OnChanges,
92184 buildComponentCssClass ( ) : string [ ] {
93185 const tagName = this . elementRef . nativeElement . tagName . toLowerCase ( ) ;
94186 return [
95- this . state ? 'is-' + this . state : '' ,
96- this . class ,
97- tagName === 'textarea' ? 'fd-textarea' : tagName === 'input' ? 'fd-input' : ''
98- ] ;
187+ tagName === 'textarea' ? 'fd-textarea' : tagName === 'input' ? 'fd-input' : '' ,
188+ this . state ( ) ? `is- ${ this . state ( ) } ` : '' ,
189+ this . class ( )
190+ ] . filter ( Boolean ) as string [ ] ;
99191 }
100192
101193 /** @hidden */
102194 ngOnInit ( ) : void {
103195 this . buildComponentCssClass ( ) ;
196+
197+ // Capture user-defined aria-describedby (if present on host element)
198+ const userAriaDescribedByValue = this . elementRef . nativeElement . getAttribute ( 'aria-describedby' ) ;
199+ if ( userAriaDescribedByValue ) {
200+ this . _userAriaDescribedBy . set ( userAriaDescribedByValue ) ;
201+ }
202+
203+ // Capture user-defined aria-errormessage (if present on host element)
204+ const userAriaErrorMessageValue = this . elementRef . nativeElement . getAttribute ( 'aria-errormessage' ) ;
205+ if ( userAriaErrorMessageValue ) {
206+ this . _userAriaErrorMessage . set ( userAriaErrorMessageValue ) ;
207+ }
208+
209+ // If the control has a state, create a hidden <span> for the value state message
210+ if ( this . state ( ) ) {
211+ this . _valueStateSpan = this . renderer . createElement ( 'span' ) ;
212+ this . renderer . setAttribute ( this . _valueStateSpan , 'id' , this . _valueStateMessageId ) ;
213+ this . renderer . addClass ( this . _valueStateSpan , 'fd-value-state-message__sr-only' ) ;
214+
215+ // Insert hidden span right after the input/textarea
216+ const parent = this . elementRef . nativeElement . parentNode ;
217+ this . renderer . insertBefore ( parent , this . _valueStateSpan , this . elementRef . nativeElement . nextSibling ) ;
218+ }
104219 }
105220
106221 /** @hidden */
@@ -110,6 +225,11 @@ export class FormControlComponent implements CssClassBuilder, OnInit, OnChanges,
110225
111226 /** @hidden */
112227 ngOnDestroy ( ) : void {
228+ if ( this . _valueStateSpan ) {
229+ this . renderer . removeChild ( this . elementRef . nativeElement . parentNode , this . _valueStateSpan ) ;
230+ this . _valueStateSpan = null ;
231+ }
232+
113233 this . _subscriptions . unsubscribe ( ) ;
114234 }
115235}
0 commit comments