Skip to content

Commit 3369e80

Browse files
authored
Merge pull request #396 from 4site-interactive-studios/enhanced-accessibility-form-errors
Accessibility Improvements: Form Field Error Alerting
2 parents 6088b4d + 0738e9a commit 3369e80

7 files changed

Lines changed: 281 additions & 10 deletions

File tree

packages/scripts/dist/a11y.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
export declare class A11y {
2+
private logger;
3+
private observer;
24
constructor();
5+
private addErrorAlertArea;
36
private addGroupRole;
47
private addRequired;
58
private addLabel;
69
private updateFrequencyLabel;
710
private setAutoGeneratedAltTags;
11+
/**
12+
* Observe #engrid for .en__field__error additions and removals, mirroring
13+
* text into the per-field .en__field__error__alert live region and toggling
14+
* aria-invalid / aria-describedby on the corresponding input. Runs for the
15+
* lifetime of the page so async validators (NeverBounce, VGS, server
16+
* re-renders) are caught without timing assumptions.
17+
*/
18+
private observeErrorMessages;
19+
private moveErrorMessage;
20+
private clearErrorMessage;
821
private manageErrorListAlertRole;
922
}

packages/scripts/dist/a11y.js

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
1+
import { EngridLogger } from "./logger";
12
// a11y means accessibility
23
// This Component is supposed to be used as a helper for Aria Attributes & Other Accessibility Features
34
export class A11y {
45
constructor() {
6+
this.logger = new EngridLogger("A11y", "#FFFFFF", "#811212", "👁️‍🗨️");
7+
this.observer = null;
8+
this.addErrorAlertArea();
59
this.addRequired();
610
this.addLabel();
711
this.addGroupRole();
812
this.updateFrequencyLabel();
913
const ecardImages = document.querySelectorAll('.en__ecarditems__list img');
1014
this.setAutoGeneratedAltTags(ecardImages);
1115
this.manageErrorListAlertRole();
16+
this.observeErrorMessages();
17+
}
18+
addErrorAlertArea() {
19+
const fieldElements = document.querySelectorAll('.en__field .en__field__element');
20+
fieldElements.forEach((fieldElement) => {
21+
var _a;
22+
if ((_a = fieldElement.nextElementSibling) === null || _a === void 0 ? void 0 : _a.classList.contains('en__field__error__alert'))
23+
return;
24+
const errorAlert = document.createElement('div');
25+
errorAlert.setAttribute('aria-live', 'polite');
26+
errorAlert.setAttribute('aria-atomic', 'true');
27+
errorAlert.classList.add('en__field__error__alert');
28+
errorAlert.id = `en__field__error__alert--${Math.random().toString(36).slice(2, 7)}`;
29+
fieldElement.insertAdjacentElement('afterend', errorAlert);
30+
});
1231
}
1332
addGroupRole() {
1433
// Add role="group" to all EN Radio fields
@@ -90,10 +109,101 @@ export class A11y {
90109
img.alt = altText;
91110
}
92111
catch (error) {
93-
console.error(`Error processing image: ${img.src}`, error);
112+
this.logger.danger(`Error processing image: ${img.src}`, error);
94113
}
95114
});
96115
}
116+
/**
117+
* Observe #engrid for .en__field__error additions and removals, mirroring
118+
* text into the per-field .en__field__error__alert live region and toggling
119+
* aria-invalid / aria-describedby on the corresponding input. Runs for the
120+
* lifetime of the page so async validators (NeverBounce, VGS, server
121+
* re-renders) are caught without timing assumptions.
122+
*/
123+
observeErrorMessages() {
124+
var _a;
125+
const root = (_a = document.getElementById('engrid')) !== null && _a !== void 0 ? _a : document.body;
126+
this.observer = new MutationObserver(records => {
127+
for (const record of records) {
128+
if (record.type !== 'childList')
129+
continue;
130+
record.addedNodes.forEach(node => {
131+
if (node instanceof HTMLElement && node.classList.contains('en__field__error')) {
132+
this.moveErrorMessage(node);
133+
}
134+
});
135+
record.removedNodes.forEach(node => {
136+
var _a, _b;
137+
if (!(node instanceof HTMLElement))
138+
return;
139+
if (!node.classList.contains('en__field__error'))
140+
return;
141+
// node.parentElement is null after removal; record.target is the
142+
// former parent. Walk up to the enclosing .en__field to be defensive
143+
// against deeper nesting.
144+
const fieldWrapper = (_b = (_a = record.target).closest) === null || _b === void 0 ? void 0 : _b.call(_a, '.en__field');
145+
const alert = fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert');
146+
if (alert)
147+
this.clearErrorMessage(alert);
148+
});
149+
}
150+
});
151+
this.observer.observe(root, { childList: true, subtree: true });
152+
// Initial sweep for errors rendered server-side or by scripts that ran
153+
// before this observer was attached.
154+
document.querySelectorAll('.en__field').forEach(field => {
155+
const error = field.querySelector('.en__field__error');
156+
const alert = field.querySelector('.en__field__error__alert');
157+
if (error)
158+
this.moveErrorMessage(error);
159+
else if (alert)
160+
this.clearErrorMessage(alert);
161+
});
162+
}
163+
moveErrorMessage(field) {
164+
var _a;
165+
if (field.closest('.en__field__error__alert'))
166+
return;
167+
const fieldWrapper = field.closest('.en__field');
168+
if (!fieldWrapper)
169+
return;
170+
const alertContainer = fieldWrapper.querySelector('.en__field__error__alert');
171+
if (!alertContainer)
172+
return;
173+
alertContainer.textContent = field.textContent;
174+
field.classList.add('en__field__error--hidden-by-a11y');
175+
const inputElement = fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
176+
if (!inputElement)
177+
return;
178+
inputElement.setAttribute('aria-invalid', 'true');
179+
const describedBy = ((_a = inputElement.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
180+
.split(/\s+/)
181+
.filter(Boolean);
182+
if (describedBy.indexOf(alertContainer.id) === -1) {
183+
describedBy.push(alertContainer.id);
184+
}
185+
inputElement.setAttribute('aria-describedby', describedBy.join(' '));
186+
}
187+
clearErrorMessage(alert) {
188+
var _a;
189+
alert.textContent = '';
190+
const fieldWrapper = alert.closest('.en__field');
191+
if (!fieldWrapper)
192+
return;
193+
const inputElement = fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
194+
if (!inputElement)
195+
return;
196+
inputElement.removeAttribute('aria-invalid');
197+
const remaining = ((_a = inputElement.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
198+
.split(/\s+/)
199+
.filter(id => id && id !== alert.id);
200+
if (remaining.length) {
201+
inputElement.setAttribute('aria-describedby', remaining.join(' '));
202+
}
203+
else {
204+
inputElement.removeAttribute('aria-describedby');
205+
}
206+
}
97207
manageErrorListAlertRole() {
98208
const errorList = document.querySelector('ul.en__errorList');
99209
if (!errorList)

packages/scripts/src/a11y.ts

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,40 @@
1+
import { EngridLogger } from "./logger";
12
// a11y means accessibility
23
// This Component is supposed to be used as a helper for Aria Attributes & Other Accessibility Features
34

45
export class A11y {
6+
private logger: EngridLogger = new EngridLogger(
7+
"A11y",
8+
"#FFFFFF",
9+
"#811212",
10+
"👁️‍🗨️"
11+
);
12+
13+
private observer: MutationObserver | null = null;
14+
515
constructor() {
16+
this.addErrorAlertArea();
617
this.addRequired();
718
this.addLabel();
819
this.addGroupRole();
920
this.updateFrequencyLabel();
1021
const ecardImages: NodeListOf<HTMLImageElement> = document.querySelectorAll('.en__ecarditems__list img');
1122
this.setAutoGeneratedAltTags(ecardImages);
1223
this.manageErrorListAlertRole();
24+
this.observeErrorMessages();
25+
}
26+
27+
private addErrorAlertArea() {
28+
const fieldElements = document.querySelectorAll('.en__field .en__field__element')
29+
fieldElements.forEach((fieldElement) => {
30+
if (fieldElement.nextElementSibling?.classList.contains('en__field__error__alert')) return;
31+
const errorAlert = document.createElement('div');
32+
errorAlert.setAttribute('aria-live', 'polite');
33+
errorAlert.setAttribute('aria-atomic', 'true');
34+
errorAlert.classList.add('en__field__error__alert');
35+
errorAlert.id = `en__field__error__alert--${Math.random().toString(36).slice(2, 7)}`;
36+
fieldElement.insertAdjacentElement('afterend', errorAlert);
37+
})
1338
}
1439

1540
private addGroupRole() {
@@ -91,32 +116,126 @@ export class A11y {
91116
images.forEach((img: HTMLImageElement) => {
92117
// Skip if the alt tag is already set
93118
if (img.alt) return;
94-
119+
95120
try {
96121
// Extract the filename from the `src` attribute
97122
const src: string | null = img.src;
98123
if (!src) throw new Error("Image src is null or undefined");
99-
124+
100125
const url = new URL(src);
101126
const fileNameWithExtension: string | undefined = url.pathname.split('/').pop();
102127
if (!fileNameWithExtension) throw new Error("No filename found in src");
103-
128+
104129
// Remove the file extension and replace `-` and `_` with spaces
105130
let altText: string = fileNameWithExtension.split('.').shift()?.replace(/[-_]/g, ' ') || '';
106-
131+
107132
// Remove dimensions (#x#) and anything that follows
108133
altText = altText.replace(/\d+x\d+.*$/, '').trim();
109-
134+
110135
// Wrap in the disclaimer
111136
altText = `This is an auto-generated alt tag from the filename: ${altText}`;
112-
137+
113138
// Set the generated alt text on the image
114139
img.alt = altText;
115140
} catch (error) {
116-
console.error(`Error processing image: ${img.src}`, error);
141+
this.logger.danger(`Error processing image: ${img.src}`, error);
142+
}
143+
});
144+
}
145+
146+
/**
147+
* Observe #engrid for .en__field__error additions and removals, mirroring
148+
* text into the per-field .en__field__error__alert live region and toggling
149+
* aria-invalid / aria-describedby on the corresponding input. Runs for the
150+
* lifetime of the page so async validators (NeverBounce, VGS, server
151+
* re-renders) are caught without timing assumptions.
152+
*/
153+
private observeErrorMessages(): void {
154+
const root = document.getElementById('engrid') ?? document.body;
155+
156+
this.observer = new MutationObserver(records => {
157+
for (const record of records) {
158+
if (record.type !== 'childList') continue;
159+
160+
record.addedNodes.forEach(node => {
161+
if (node instanceof HTMLElement && node.classList.contains('en__field__error')) {
162+
this.moveErrorMessage(node);
163+
}
164+
});
165+
166+
record.removedNodes.forEach(node => {
167+
if (!(node instanceof HTMLElement)) return;
168+
if (!node.classList.contains('en__field__error')) return;
169+
// node.parentElement is null after removal; record.target is the
170+
// former parent. Walk up to the enclosing .en__field to be defensive
171+
// against deeper nesting.
172+
const fieldWrapper = (record.target as HTMLElement).closest?.('.en__field');
173+
const alert = fieldWrapper?.querySelector<HTMLElement>('.en__field__error__alert');
174+
if (alert) this.clearErrorMessage(alert);
175+
});
117176
}
118177
});
178+
179+
this.observer.observe(root, { childList: true, subtree: true });
180+
181+
// Initial sweep for errors rendered server-side or by scripts that ran
182+
// before this observer was attached.
183+
document.querySelectorAll<HTMLElement>('.en__field').forEach(field => {
184+
const error = field.querySelector<HTMLElement>('.en__field__error');
185+
const alert = field.querySelector<HTMLElement>('.en__field__error__alert');
186+
if (error) this.moveErrorMessage(error);
187+
else if (alert) this.clearErrorMessage(alert);
188+
});
189+
}
190+
191+
private moveErrorMessage(field: HTMLElement): void {
192+
if (field.closest('.en__field__error__alert')) return;
193+
const fieldWrapper = field.closest('.en__field');
194+
if (!fieldWrapper) return;
195+
196+
const alertContainer = fieldWrapper.querySelector<HTMLElement>('.en__field__error__alert');
197+
if (!alertContainer) return;
198+
199+
alertContainer.textContent = field.textContent;
200+
field.classList.add('en__field__error--hidden-by-a11y');
201+
202+
const inputElement = fieldWrapper.querySelector<HTMLElement>(
203+
'.en__field__element input, .en__field__element select, .en__field__element textarea'
204+
);
205+
if (!inputElement) return;
206+
207+
inputElement.setAttribute('aria-invalid', 'true');
208+
const describedBy = (inputElement.getAttribute('aria-describedby') ?? '')
209+
.split(/\s+/)
210+
.filter(Boolean);
211+
if (describedBy.indexOf(alertContainer.id) === -1) {
212+
describedBy.push(alertContainer.id);
213+
}
214+
inputElement.setAttribute('aria-describedby', describedBy.join(' '));
215+
}
216+
217+
private clearErrorMessage(alert: HTMLElement): void {
218+
alert.textContent = '';
219+
220+
const fieldWrapper = alert.closest('.en__field');
221+
if (!fieldWrapper) return;
222+
223+
const inputElement = fieldWrapper.querySelector<HTMLElement>(
224+
'.en__field__element input, .en__field__element select, .en__field__element textarea'
225+
);
226+
if (!inputElement) return;
227+
228+
inputElement.removeAttribute('aria-invalid');
229+
const remaining = (inputElement.getAttribute('aria-describedby') ?? '')
230+
.split(/\s+/)
231+
.filter(id => id && id !== alert.id);
232+
if (remaining.length) {
233+
inputElement.setAttribute('aria-describedby', remaining.join(' '));
234+
} else {
235+
inputElement.removeAttribute('aria-describedby');
236+
}
119237
}
238+
120239
private manageErrorListAlertRole(): void {
121240
const errorList = document.querySelector<HTMLUListElement>('ul.en__errorList');
122241
if (!errorList) return;

packages/styles/dist/main.css

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/styles/dist/main.css.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.en__field__error__alert {
2+
color: var(--error__color);
3+
font-family: var(--p_font-family);
4+
font-size: max(75%, 10px);
5+
font-weight: var(--p_font-weight);
6+
line-height: var(--p_line-height);
7+
order: 3;
8+
}
9+
10+
// The original .en__field__error is hidden once its text has been mirrored into
11+
// the .en__field__error__alert live region by the A11y component, so the alert
12+
// region remains the single visible/announced source of error text.
13+
.en__field__error--hidden-by-a11y {
14+
display: none !important;
15+
}

packages/styles/src/main.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@import "engrid-fields";
1111

1212
/* ENGRID STYLINGS */
13+
@import "engrid-a11y";
1314
@import "engrid-apple-pay";
1415
@import "engrid-autofill";
1516
@import "engrid-background-image";
@@ -79,4 +80,4 @@
7980
/* ENGRID ADMIN ONLY STYLINGS */
8081
/* @TODO Move Debug and Page Builder styles into their own CSS file or Repo / NPM dependency */
8182
@import "engrid-debug";
82-
@import "engrid-page-builder";
83+
@import "engrid-page-builder";

0 commit comments

Comments
 (0)