Skip to content

Commit 064ff0f

Browse files
authored
Merge pull request #407 from 4site-interactive-studios/enhanced-accessibility-ecards
Enhanced Accessibility of eCard Forms and Supporter Hubs (pt 2)
2 parents cb2a5aa + 6002633 commit 064ff0f

14 files changed

Lines changed: 1197 additions & 180 deletions

packages/scripts/dist/a11y.d.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@ export declare class A11y {
22
private logger;
33
private observer;
44
constructor();
5-
private addErrorAlertArea;
6-
private addGroupRole;
7-
private addRequired;
8-
private addLabel;
5+
/**
6+
* Apply the field-level accessibility tagging (error alert live regions,
7+
* aria-required, fallback aria-labels, radio group roles) to every field
8+
* within `root`. Defaults to the whole document on initial load, but can be
9+
* pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
10+
* dynamically added forms get the same treatment. All operations are
11+
* idempotent, so re-scanning already tagged fields is safe.
12+
*/
13+
static scanFields(root?: ParentNode): void;
14+
private static addErrorAlertArea;
15+
private static addGroupRole;
16+
private static addRequired;
17+
private static addLabel;
918
private updateFrequencyLabel;
1019
private setAutoGeneratedAltTags;
1120
/**
@@ -18,5 +27,18 @@ export declare class A11y {
1827
private observeErrorMessages;
1928
private moveErrorMessage;
2029
private clearErrorMessage;
30+
/**
31+
* Make everything on the page inert except the supplied overlay element and
32+
* its ancestors. This hides background content from assistive technology and
33+
* prevents focus from escaping a modal-style overlay.
34+
*
35+
* @param inert When true, set `inert` on all siblings of the overlay and of
36+
* each of its ancestors. When false, remove `inert` from every
37+
* element this method previously marked (tracked via the
38+
* `data-engrid-inert` flag).
39+
* @param overlay The element that should remain interactive. Required when
40+
* `inert` is true; ignored when `inert` is false.
41+
*/
42+
static inertPage(inert: boolean, overlay?: HTMLElement): void;
2143
private manageErrorListAlertRole;
2244
}

packages/scripts/dist/a11y.js

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,32 @@ export class A11y {
55
constructor() {
66
this.logger = new EngridLogger("A11y", "#FFFFFF", "#811212", "👁️‍🗨️");
77
this.observer = null;
8-
this.addErrorAlertArea();
9-
this.addRequired();
10-
this.addLabel();
11-
this.addGroupRole();
8+
A11y.scanFields();
129
this.updateFrequencyLabel();
1310
const ecardImages = document.querySelectorAll('.en__ecarditems__list img');
1411
this.setAutoGeneratedAltTags(ecardImages);
1512
this.manageErrorListAlertRole();
1613
this.observeErrorMessages();
1714
}
18-
addErrorAlertArea() {
19-
const fieldElements = document.querySelectorAll('.en__field .en__field__element');
15+
/**
16+
* Apply the field-level accessibility tagging (error alert live regions,
17+
* aria-required, fallback aria-labels, radio group roles) to every field
18+
* within `root`. Defaults to the whole document on initial load, but can be
19+
* pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
20+
* dynamically added forms get the same treatment. All operations are
21+
* idempotent, so re-scanning already tagged fields is safe.
22+
*/
23+
static scanFields(root = document) {
24+
A11y.addErrorAlertArea(root);
25+
A11y.addRequired(root);
26+
A11y.addLabel(root);
27+
A11y.addGroupRole(root);
28+
}
29+
static addErrorAlertArea(root = document) {
30+
const fieldElements = root.querySelectorAll('.en__field .en__field__element');
2031
fieldElements.forEach((fieldElement) => {
21-
var _a;
22-
if ((_a = fieldElement.nextElementSibling) === null || _a === void 0 ? void 0 : _a.classList.contains('en__field__error__alert'))
32+
const fieldWrapper = fieldElement.closest('.en__field');
33+
if (fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert'))
2334
return;
2435
const errorAlert = document.createElement('div');
2536
errorAlert.setAttribute('aria-live', 'polite');
@@ -29,10 +40,13 @@ export class A11y {
2940
fieldElement.insertAdjacentElement('afterend', errorAlert);
3041
});
3142
}
32-
addGroupRole() {
43+
static addGroupRole(root = document) {
3344
// Add role="group" to all EN Radio fields
34-
const radioFields = document.querySelectorAll(".en__field--radio");
45+
const radioFields = root.querySelectorAll(".en__field--radio");
3546
radioFields.forEach((field) => {
47+
// Skip fields already tagged so re-scans don't regenerate label IDs.
48+
if (field.getAttribute("role") === "group")
49+
return;
3650
field.setAttribute("role", "group");
3751
// Add random ID to the label
3852
const label = field.querySelector("label");
@@ -42,19 +56,19 @@ export class A11y {
4256
}
4357
});
4458
}
45-
addRequired() {
46-
const mandatoryFields = document.querySelectorAll(".en__mandatory .en__field__input");
59+
static addRequired(root = document) {
60+
const mandatoryFields = root.querySelectorAll(".en__mandatory .en__field__input");
4761
mandatoryFields.forEach((field) => {
4862
field.setAttribute("aria-required", "true");
4963
});
5064
}
51-
addLabel() {
52-
const otherAmount = document.querySelector(".en__field__input--otheramount");
65+
static addLabel(root = document) {
66+
const otherAmount = root.querySelector(".en__field__input--otheramount");
5367
if (otherAmount) {
5468
otherAmount.setAttribute("aria-label", "Enter your custom donation amount");
5569
}
5670
// Split selects usually don't have a label, so let's make the first option the label
57-
const splitSelects = document.querySelectorAll(".en__field__input--splitselect");
71+
const splitSelects = root.querySelectorAll(".en__field__input--splitselect");
5872
splitSelects.forEach((select) => {
5973
var _a, _b, _c, _d;
6074
const firstOption = select.querySelector("option");
@@ -204,6 +218,47 @@ export class A11y {
204218
inputElement.removeAttribute('aria-describedby');
205219
}
206220
}
221+
/**
222+
* Make everything on the page inert except the supplied overlay element and
223+
* its ancestors. This hides background content from assistive technology and
224+
* prevents focus from escaping a modal-style overlay.
225+
*
226+
* @param inert When true, set `inert` on all siblings of the overlay and of
227+
* each of its ancestors. When false, remove `inert` from every
228+
* element this method previously marked (tracked via the
229+
* `data-engrid-inert` flag).
230+
* @param overlay The element that should remain interactive. Required when
231+
* `inert` is true; ignored when `inert` is false.
232+
*/
233+
static inertPage(inert, overlay) {
234+
if (inert) {
235+
if (!overlay)
236+
return;
237+
let element = overlay;
238+
while (element && element !== document.body) {
239+
const parent = element.parentElement;
240+
if (parent) {
241+
Array.from(parent.children).forEach((sibling) => {
242+
if (sibling !== element &&
243+
sibling instanceof HTMLElement &&
244+
!sibling.hasAttribute("inert")) {
245+
sibling.setAttribute("inert", "");
246+
sibling.dataset.engridInert = "true";
247+
}
248+
});
249+
}
250+
element = parent;
251+
}
252+
}
253+
else {
254+
document
255+
.querySelectorAll("[data-engrid-inert]")
256+
.forEach((element) => {
257+
element.removeAttribute("inert");
258+
delete element.dataset.engridInert;
259+
});
260+
}
261+
}
207262
manageErrorListAlertRole() {
208263
const errorList = document.querySelector('ul.en__errorList');
209264
if (!errorList)

packages/scripts/dist/ecard.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ export declare class Ecard {
55
constructor();
66
private shouldRun;
77
private checkRecipientFields;
8+
private altsAndArias;
9+
private altsAndAriasEcardItemsList;
10+
private coupleLabelAndInput;
11+
private coupleH2AndInput;
812
}

packages/scripts/dist/ecard.js

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { ENGrid, EnForm, EngridLogger } from ".";
1+
import { ENGrid, EnForm, EngridLogger, A11y } from ".";
22
export class Ecard {
33
constructor() {
44
this._form = EnForm.getInstance();
55
this.logger = new EngridLogger("Ecard", "red", "#f5f5f5", "🪪");
66
if (!this.shouldRun())
77
return;
8+
this.altsAndArias();
89
this._form.onValidate.subscribe(() => this.checkRecipientFields());
910
const schedule = ENGrid.getUrlParameter("engrid_ecard.schedule");
1011
const scheduleField = ENGrid.getField("ecard.schedule");
@@ -58,4 +59,198 @@ export class Ecard {
5859
}
5960
return true;
6061
}
62+
altsAndArias() {
63+
document.querySelectorAll(".en__ecarditems__list").forEach((list) => {
64+
this.altsAndAriasEcardItemsList(list);
65+
});
66+
const ecardMessage = document.querySelector(".en__ecardmessage");
67+
if (ecardMessage) {
68+
this.coupleH2AndInput(ecardMessage, "Add a Message to your eCard");
69+
}
70+
const ecardRecipients = document.querySelector(".en__ecardrecipients");
71+
if (ecardRecipients) {
72+
const recipientName = ecardRecipients.querySelector(".en__ecardrecipients__name");
73+
if (recipientName) {
74+
this.coupleLabelAndInput(recipientName, "Recipient Name");
75+
}
76+
const recipientEmail = ecardRecipients.querySelector(".en__ecardrecipients__email");
77+
if (recipientEmail) {
78+
this.coupleLabelAndInput(recipientEmail, "Recipient Email");
79+
}
80+
}
81+
const ecardFutureDelivery = document.querySelector(".en__ecardrecipients__futureDelivery");
82+
if (ecardFutureDelivery) {
83+
this.coupleH2AndInput(ecardFutureDelivery, "Schedule your eCard for future delivery");
84+
}
85+
const previewButton = document.querySelector(".en__ecarditems__showprev");
86+
if (previewButton) {
87+
previewButton.setAttribute("aria-controls", "ecard-preview");
88+
previewButton.setAttribute("aria-haspopup", "dialog");
89+
}
90+
const previewModal = document.querySelector(".en__ecarditems__preview");
91+
if (previewModal) {
92+
previewModal.setAttribute("role", "dialog");
93+
previewModal.setAttribute("aria-modal", "true");
94+
previewModal.setAttribute("aria-label", "Ecard Preview Modal");
95+
previewModal.setAttribute("id", "ecard-preview");
96+
const closeButton = previewModal.querySelector(".en__ecarditems__prevclose");
97+
if (closeButton) {
98+
closeButton.setAttribute("role", "button");
99+
closeButton.setAttribute("aria-label", "Close Preview");
100+
document.addEventListener("keydown", (e) => {
101+
if (e.key === "Escape" &&
102+
previewModal.classList.contains("preview--show")) {
103+
closeButton.click();
104+
}
105+
});
106+
}
107+
const iframe = previewModal.querySelector("iframe");
108+
if (iframe) {
109+
iframe.setAttribute("title", "Ecard Preview Frame");
110+
}
111+
const observer = new MutationObserver((mutations) => {
112+
mutations.forEach((mutation) => {
113+
if (mutation.type === "attributes" &&
114+
mutation.attributeName === "class") {
115+
const target = mutation.target;
116+
if (target.classList.contains("preview--show")) {
117+
A11y.inertPage(true, previewModal);
118+
// Focus the iframe or the first focusable element in the modal
119+
const focusableElements = previewModal.querySelectorAll('iframe, a[href], area[href], button:not([disabled]), object, embed, [tabindex="0"]');
120+
if (focusableElements.length) {
121+
focusableElements[0].focus();
122+
}
123+
}
124+
else {
125+
A11y.inertPage(false);
126+
// Return focus to the preview button
127+
if (previewButton) {
128+
previewButton.focus();
129+
}
130+
}
131+
}
132+
});
133+
});
134+
observer.observe(previewModal, {
135+
attributes: true,
136+
attributeFilter: ["class"],
137+
});
138+
}
139+
}
140+
altsAndAriasEcardItemsList(list) {
141+
// if there's a sibling h2, use its text as the aria-label for the list
142+
const h2 = list.previousElementSibling;
143+
if (h2 && h2.tagName === "H2") {
144+
const id = `ecard-list-${Math.random().toString(36).substring(2, 9)}`;
145+
h2.setAttribute("id", id);
146+
list.setAttribute("aria-labelledby", id);
147+
}
148+
list.setAttribute("role", "radiogroup");
149+
const thumbs = Array.from(list.querySelectorAll(".en__ecarditems__thumb"));
150+
let isSelection = false;
151+
thumbs.forEach((thumb, index) => {
152+
thumb.setAttribute("role", "radio");
153+
if (thumb.classList.contains("thumb--active")) {
154+
thumb.setAttribute("aria-checked", "true");
155+
thumb.setAttribute("tabindex", "0");
156+
isSelection = true;
157+
}
158+
else {
159+
thumb.setAttribute("aria-checked", "false");
160+
thumb.setAttribute("tabindex", "-1");
161+
}
162+
const img = thumb.querySelector("img");
163+
if (img) {
164+
thumb.setAttribute("aria-label", img.alt || "Ecard Thumbnail");
165+
img.setAttribute("aria-hidden", "true");
166+
}
167+
// Keyboard navigation (WAI-ARIA radio group pattern)
168+
thumb.addEventListener("keydown", (e) => {
169+
let nextIndex = null;
170+
switch (e.key) {
171+
case "ArrowRight":
172+
case "ArrowDown":
173+
nextIndex = (index + 1) % thumbs.length;
174+
break;
175+
case "ArrowLeft":
176+
case "ArrowUp":
177+
nextIndex = (index - 1 + thumbs.length) % thumbs.length;
178+
break;
179+
case "Home":
180+
nextIndex = 0;
181+
break;
182+
case "End":
183+
nextIndex = thumbs.length - 1;
184+
break;
185+
case "Enter":
186+
case " ":
187+
e.preventDefault();
188+
thumb.click();
189+
return;
190+
default:
191+
return;
192+
}
193+
e.preventDefault();
194+
// In a radio group, moving focus also selects the option.
195+
// click() lets EN's own handler set the value + thumb--active class;
196+
// the MutationObserver below then syncs aria-checked + tabindex.
197+
thumbs[nextIndex].focus();
198+
thumbs[nextIndex].click();
199+
});
200+
});
201+
if (!isSelection && thumbs.length) {
202+
thumbs[0].setAttribute("tabindex", "0");
203+
}
204+
// MutationObserver to watch for "thumb--active" class changes and keep
205+
// aria-checked + roving tabindex in sync with the selected thumb
206+
const observer = new MutationObserver((mutations) => {
207+
mutations.forEach((mutation) => {
208+
if (mutation.type === "attributes" &&
209+
mutation.attributeName === "class") {
210+
const target = mutation.target;
211+
if (target.classList.contains("thumb--active")) {
212+
target.setAttribute("aria-checked", "true");
213+
// Roving tabindex: only the active thumb is tabbable
214+
target.setAttribute("tabindex", "0");
215+
thumbs.forEach((t) => {
216+
if (t !== target)
217+
t.setAttribute("tabindex", "-1");
218+
});
219+
}
220+
else {
221+
target.setAttribute("aria-checked", "false");
222+
}
223+
}
224+
});
225+
});
226+
observer.observe(list, {
227+
attributes: true,
228+
subtree: true,
229+
attributeFilter: ["class"],
230+
});
231+
}
232+
coupleLabelAndInput(parent, labelText) {
233+
const label = parent.querySelector("label");
234+
const input = parent.querySelector("input, textarea, select");
235+
if (label && input) {
236+
const id = `ecard-input-${Math.random().toString(36).substring(2, 9)}`;
237+
label.setAttribute("id", id);
238+
input.setAttribute("aria-labelledby", id);
239+
}
240+
else if (input) {
241+
input.setAttribute("aria-label", labelText);
242+
}
243+
}
244+
coupleH2AndInput(parent, labelText) {
245+
const h2 = parent.querySelector("h2");
246+
const input = parent.querySelector("textarea, input, select");
247+
if (h2 && input) {
248+
const id = `ecard-message-${Math.random().toString(36).substring(2, 9)}`;
249+
h2.setAttribute("id", id);
250+
input.setAttribute("aria-labelledby", id);
251+
}
252+
else if (input) {
253+
input.setAttribute("aria-label", labelText);
254+
}
255+
}
61256
}

0 commit comments

Comments
 (0)