Skip to content

Commit f675e16

Browse files
fix(core): adopt a11y breaking changes for Textarea and Input states (#13506)
* fix(core): adopt a11y breaking changes for Textarea states * fix(core): a11y for input and textarea
1 parent 796b60f commit f675e16

File tree

14 files changed

+344
-229
lines changed

14 files changed

+344
-229
lines changed

libs/core/form/form-control/form-control.component.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,17 @@
2525
.fd-input[readonly]:focus {
2626
z-index: 1 !important;
2727
}
28+
29+
.fd-value-state-message__sr-only {
30+
position: absolute;
31+
clip: rect(0 0 0 0);
32+
height: 1px;
33+
width: 1px;
34+
border: 0;
35+
margin-inline: -1px;
36+
margin-block: -1px;
37+
padding-inline: 0;
38+
padding-block: 0;
39+
overflow: hidden;
40+
white-space: nowrap;
41+
}
Lines changed: 165 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,177 @@
11
import {
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';
1317
import { 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';
1519
import { ContentDensityObserver, contentDensityObserverProviders } from '@fundamental-ngx/core/content-density';
20+
import { ValueStateAriaMessageService } from '@fundamental-ngx/core/shared';
1621
import { Subscription } from 'rxjs';
1722
import { 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
})
3541
export 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
}

libs/core/form/form-item-control/form-item-control.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ElementRef, InjectionToken, Provider, Type } from '@angular/core';
1+
import { ElementRef, InjectionToken, Provider, Type, WritableSignal } from '@angular/core';
22
import { Nullable } from '@fundamental-ngx/cdk/utils';
33

44
/** An injection token, that should be used with all controls, that can be put inside `fd-form-item` */
@@ -17,4 +17,5 @@ export function registerFormItemControl(control: Type<FormItemControl>): Provide
1717
export interface FormItemControl {
1818
ariaLabelledBy: Nullable<string>;
1919
elmRef?: ElementRef;
20+
formItemAriaDescribedBy?: WritableSignal<Nullable<string>>;
2021
}

libs/core/form/form-item/form-item.component.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ $form-label-margin: 0.5rem;
2121
}
2222
}
2323
}
24+
25+
/* Disable label pointer events if the same form-item contains a disabled input/textarea */
26+
.fd-form-item:has(input[disabled], textarea[disabled], input.is-disabled, textarea.is-disabled)
27+
.fd-form-label__wrapper {
28+
pointer-events: none;
29+
}

0 commit comments

Comments
 (0)