@4site/engrid-scripts 0.25.9 → 0.25.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/a11y.d.ts +26 -4
- package/dist/a11y.js +70 -15
- package/dist/ecard.d.ts +4 -0
- package/dist/ecard.js +196 -1
- package/dist/supporter-hub.d.ts +41 -1
- package/dist/supporter-hub.js +227 -39
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/a11y.d.ts
CHANGED
|
@@ -2,10 +2,19 @@ export declare class A11y {
|
|
|
2
2
|
private logger;
|
|
3
3
|
private observer;
|
|
4
4
|
constructor();
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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;
|
|
9
18
|
private updateFrequencyLabel;
|
|
10
19
|
private setAutoGeneratedAltTags;
|
|
11
20
|
/**
|
|
@@ -18,5 +27,18 @@ export declare class A11y {
|
|
|
18
27
|
private observeErrorMessages;
|
|
19
28
|
private moveErrorMessage;
|
|
20
29
|
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;
|
|
21
43
|
private manageErrorListAlertRole;
|
|
22
44
|
}
|
package/dist/a11y.js
CHANGED
|
@@ -5,21 +5,32 @@ export class A11y {
|
|
|
5
5
|
constructor() {
|
|
6
6
|
this.logger = new EngridLogger("A11y", "#FFFFFF", "#811212", "👁️🗨️");
|
|
7
7
|
this.observer = null;
|
|
8
|
-
|
|
9
|
-
this.addRequired();
|
|
10
|
-
this.addLabel();
|
|
11
|
-
this.addGroupRole();
|
|
8
|
+
A11y.scanFields();
|
|
12
9
|
this.updateFrequencyLabel();
|
|
13
10
|
const ecardImages = document.querySelectorAll('.en__ecarditems__list img');
|
|
14
11
|
this.setAutoGeneratedAltTags(ecardImages);
|
|
15
12
|
this.manageErrorListAlertRole();
|
|
16
13
|
this.observeErrorMessages();
|
|
17
14
|
}
|
|
18
|
-
|
|
19
|
-
|
|
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');
|
|
20
31
|
fieldElements.forEach((fieldElement) => {
|
|
21
|
-
|
|
22
|
-
if (
|
|
32
|
+
const fieldWrapper = fieldElement.closest('.en__field');
|
|
33
|
+
if (fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert'))
|
|
23
34
|
return;
|
|
24
35
|
const errorAlert = document.createElement('div');
|
|
25
36
|
errorAlert.setAttribute('aria-live', 'polite');
|
|
@@ -29,10 +40,13 @@ export class A11y {
|
|
|
29
40
|
fieldElement.insertAdjacentElement('afterend', errorAlert);
|
|
30
41
|
});
|
|
31
42
|
}
|
|
32
|
-
addGroupRole() {
|
|
43
|
+
static addGroupRole(root = document) {
|
|
33
44
|
// Add role="group" to all EN Radio fields
|
|
34
|
-
const radioFields =
|
|
45
|
+
const radioFields = root.querySelectorAll(".en__field--radio");
|
|
35
46
|
radioFields.forEach((field) => {
|
|
47
|
+
// Skip fields already tagged so re-scans don't regenerate label IDs.
|
|
48
|
+
if (field.getAttribute("role") === "group")
|
|
49
|
+
return;
|
|
36
50
|
field.setAttribute("role", "group");
|
|
37
51
|
// Add random ID to the label
|
|
38
52
|
const label = field.querySelector("label");
|
|
@@ -42,19 +56,19 @@ export class A11y {
|
|
|
42
56
|
}
|
|
43
57
|
});
|
|
44
58
|
}
|
|
45
|
-
addRequired() {
|
|
46
|
-
const mandatoryFields =
|
|
59
|
+
static addRequired(root = document) {
|
|
60
|
+
const mandatoryFields = root.querySelectorAll(".en__mandatory .en__field__input");
|
|
47
61
|
mandatoryFields.forEach((field) => {
|
|
48
62
|
field.setAttribute("aria-required", "true");
|
|
49
63
|
});
|
|
50
64
|
}
|
|
51
|
-
addLabel() {
|
|
52
|
-
const otherAmount =
|
|
65
|
+
static addLabel(root = document) {
|
|
66
|
+
const otherAmount = root.querySelector(".en__field__input--otheramount");
|
|
53
67
|
if (otherAmount) {
|
|
54
68
|
otherAmount.setAttribute("aria-label", "Enter your custom donation amount");
|
|
55
69
|
}
|
|
56
70
|
// Split selects usually don't have a label, so let's make the first option the label
|
|
57
|
-
const splitSelects =
|
|
71
|
+
const splitSelects = root.querySelectorAll(".en__field__input--splitselect");
|
|
58
72
|
splitSelects.forEach((select) => {
|
|
59
73
|
var _a, _b, _c, _d;
|
|
60
74
|
const firstOption = select.querySelector("option");
|
|
@@ -204,6 +218,47 @@ export class A11y {
|
|
|
204
218
|
inputElement.removeAttribute('aria-describedby');
|
|
205
219
|
}
|
|
206
220
|
}
|
|
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
|
+
}
|
|
207
262
|
manageErrorListAlertRole() {
|
|
208
263
|
const errorList = document.querySelector('ul.en__errorList');
|
|
209
264
|
if (!errorList)
|
package/dist/ecard.d.ts
CHANGED
package/dist/ecard.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { ENGrid, EnForm, EngridLogger } from ".";
|
|
1
|
+
import { ENGrid, EnForm, EngridLogger, A11y } from ".";
|
|
2
2
|
export class Ecard {
|
|
3
3
|
constructor() {
|
|
4
4
|
this._form = EnForm.getInstance();
|
|
5
5
|
this.logger = new EngridLogger("Ecard", "red", "#f5f5f5", "🪪");
|
|
6
6
|
if (!this.shouldRun())
|
|
7
7
|
return;
|
|
8
|
+
this.altsAndArias();
|
|
8
9
|
this._form.onValidate.subscribe(() => this.checkRecipientFields());
|
|
9
10
|
const schedule = ENGrid.getUrlParameter("engrid_ecard.schedule");
|
|
10
11
|
const scheduleField = ENGrid.getField("ecard.schedule");
|
|
@@ -58,4 +59,198 @@ export class Ecard {
|
|
|
58
59
|
}
|
|
59
60
|
return true;
|
|
60
61
|
}
|
|
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
|
+
}
|
|
61
256
|
}
|
package/dist/supporter-hub.d.ts
CHANGED
|
@@ -5,9 +5,49 @@ export declare class SupporterHub {
|
|
|
5
5
|
shoudRun(): boolean;
|
|
6
6
|
watch(): void;
|
|
7
7
|
pageAltsAndArias(): void;
|
|
8
|
+
/**
|
|
9
|
+
* The Supporter Hub login form shows success / failure / loading messages by
|
|
10
|
+
* toggling the inline `display` on static `.en__hubgadget__response` divs.
|
|
11
|
+
* Wire the login body up to the shared response announcer.
|
|
12
|
+
*/
|
|
13
|
+
private announceLoginResponses;
|
|
14
|
+
/**
|
|
15
|
+
* Mirror `.en__hubgadget__response` messages within `container` into screen
|
|
16
|
+
* reader live regions. Engaging Networks owns these responses, and assistive
|
|
17
|
+
* tech doesn't reliably announce content that was already in the DOM and
|
|
18
|
+
* merely flipped to visible — and in hub overlays the responses don't even
|
|
19
|
+
* exist until a submit injects them.
|
|
20
|
+
*
|
|
21
|
+
* We append a polite (`role="status"`) and an assertive (`role="alert"`) live
|
|
22
|
+
* region, hide the originals from AT, then on any childList / display / class
|
|
23
|
+
* change we debounce and announce whichever response is currently visible,
|
|
24
|
+
* preferring a terminal result over the transient "Loading" so an instant
|
|
25
|
+
* failure isn't preceded by a stray "Loading". Failures (and, for overlays,
|
|
26
|
+
* all messages) use the assertive region; everything else uses the polite one.
|
|
27
|
+
*
|
|
28
|
+
* This is a form-agnostic engine: callers supply their own side effects via
|
|
29
|
+
* `onResult` rather than this method knowing about any specific form.
|
|
30
|
+
*
|
|
31
|
+
* @param container The element that holds (or will hold) the responses.
|
|
32
|
+
* @param options `preferAssertive` announces success assertively too —
|
|
33
|
+
* overlays redraw their content (and shift focus) on success,
|
|
34
|
+
* which makes screen readers drop polite announcements, so
|
|
35
|
+
* assertive ones are used to survive that. `onResult` is fired
|
|
36
|
+
* with each settled result (and the assertive region's id) so
|
|
37
|
+
* callers can react — e.g. the login form flags its email
|
|
38
|
+
* field invalid — without baking that into the engine.
|
|
39
|
+
*/
|
|
40
|
+
private announceHubResponses;
|
|
41
|
+
/**
|
|
42
|
+
* Run the field-level accessibility scan and response announcer against a
|
|
43
|
+
* freshly opened overlay. EN injects overlay markup (and its fields) when the
|
|
44
|
+
* gadget is clicked, so A11y's constructor-time sweep never sees them. The
|
|
45
|
+
* scan is idempotent and the announcer guards against double-wiring, so a
|
|
46
|
+
* delayed pass — matching the timing of the other overlay handlers — is safe.
|
|
47
|
+
*/
|
|
48
|
+
private accessibilityScan;
|
|
8
49
|
creditCardUpdate(overlay: HTMLDivElement): void;
|
|
9
50
|
amountLabelUpdate(overlay: HTMLDivElement): void;
|
|
10
51
|
dialogAltsAndArias(overlay: HTMLDivElement): void;
|
|
11
|
-
inertPage(inert: boolean, overlay?: HTMLDivElement): void;
|
|
12
52
|
private preventDuplicateSubmits;
|
|
13
53
|
}
|
package/dist/supporter-hub.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Component that adds 4Site Special Features to the Supporter Hub Page
|
|
2
|
-
import { ENGrid, EngridLogger, EnForm } from ".";
|
|
2
|
+
import { ENGrid, EngridLogger, EnForm, A11y } from ".";
|
|
3
3
|
export class SupporterHub {
|
|
4
4
|
constructor() {
|
|
5
5
|
this.logger = new EngridLogger("SupporterHub", "black", "pink", "🛖");
|
|
@@ -10,6 +10,9 @@ export class SupporterHub {
|
|
|
10
10
|
this.watch();
|
|
11
11
|
this.preventDuplicateSubmits();
|
|
12
12
|
this.pageAltsAndArias();
|
|
13
|
+
if (ENGrid.getPageNumber() === 1) {
|
|
14
|
+
this.announceLoginResponses();
|
|
15
|
+
}
|
|
13
16
|
}
|
|
14
17
|
shoudRun() {
|
|
15
18
|
return ("pageJson" in window &&
|
|
@@ -28,9 +31,10 @@ export class SupporterHub {
|
|
|
28
31
|
if (overlay.classList.contains("en__hubOverlay") ||
|
|
29
32
|
overlay.classList.contains("en__hubPledge__panels")) {
|
|
30
33
|
this.logger.log("Overlay found");
|
|
31
|
-
this.creditCardUpdate(
|
|
32
|
-
this.amountLabelUpdate(
|
|
33
|
-
this.dialogAltsAndArias(
|
|
34
|
+
this.creditCardUpdate(overlay);
|
|
35
|
+
this.amountLabelUpdate(overlay);
|
|
36
|
+
this.dialogAltsAndArias(overlay);
|
|
37
|
+
this.accessibilityScan(overlay);
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
});
|
|
@@ -40,7 +44,11 @@ export class SupporterHub {
|
|
|
40
44
|
if (overlay.classList.contains("en__hubOverlay") ||
|
|
41
45
|
overlay.classList.contains("en__hubPledge__panels")) {
|
|
42
46
|
this.logger.log("Overlay removed");
|
|
43
|
-
|
|
47
|
+
A11y.inertPage(false);
|
|
48
|
+
const remainingOverlay = document.querySelector(".en__hubOverlay, .en__hubPledge__panels");
|
|
49
|
+
if (remainingOverlay) {
|
|
50
|
+
A11y.inertPage(true, remainingOverlay);
|
|
51
|
+
}
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
});
|
|
@@ -58,6 +66,7 @@ export class SupporterHub {
|
|
|
58
66
|
this.creditCardUpdate(hubOverlay);
|
|
59
67
|
this.amountLabelUpdate(hubOverlay);
|
|
60
68
|
this.dialogAltsAndArias(hubOverlay);
|
|
69
|
+
this.accessibilityScan(hubOverlay);
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
pageAltsAndArias() {
|
|
@@ -86,6 +95,208 @@ export class SupporterHub {
|
|
|
86
95
|
});
|
|
87
96
|
}
|
|
88
97
|
});
|
|
98
|
+
const emailField = document.querySelector('.en__field--hublogin');
|
|
99
|
+
if (emailField) {
|
|
100
|
+
const label = emailField.querySelector('label');
|
|
101
|
+
const input = emailField.querySelector('input');
|
|
102
|
+
if (label && input) {
|
|
103
|
+
const slug = ENGrid.slugify(label.innerText);
|
|
104
|
+
const labelId = `hublogin-label-${slug}`;
|
|
105
|
+
label.setAttribute("id", labelId);
|
|
106
|
+
input.setAttribute("aria-labelledby", labelId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The Supporter Hub login form shows success / failure / loading messages by
|
|
112
|
+
* toggling the inline `display` on static `.en__hubgadget__response` divs.
|
|
113
|
+
* Wire the login body up to the shared response announcer.
|
|
114
|
+
*/
|
|
115
|
+
announceLoginResponses() {
|
|
116
|
+
const body = document.querySelector(".en__supporterHubLogin__body");
|
|
117
|
+
if (!body)
|
|
118
|
+
return;
|
|
119
|
+
const emailInput = body.querySelector(".en__field--hublogin input");
|
|
120
|
+
const emailField = emailInput === null || emailInput === void 0 ? void 0 : emailInput.closest(".en__field--hublogin");
|
|
121
|
+
// Login failures are about the email field: flag it invalid and point AT at
|
|
122
|
+
// the alert describing why. This is login-specific, so it lives here rather
|
|
123
|
+
// than in the shared announcer.
|
|
124
|
+
this.announceHubResponses(body, {
|
|
125
|
+
onResult: (isFailure, alertRegionId) => {
|
|
126
|
+
if (emailInput) {
|
|
127
|
+
if (isFailure) {
|
|
128
|
+
emailInput.setAttribute("aria-invalid", "true");
|
|
129
|
+
emailInput.setAttribute("aria-describedby", alertRegionId);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
emailInput.removeAttribute("aria-invalid");
|
|
133
|
+
emailInput.removeAttribute("aria-describedby");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
emailField === null || emailField === void 0 ? void 0 : emailField.classList.toggle("en__field--validationFailed", isFailure);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Mirror `.en__hubgadget__response` messages within `container` into screen
|
|
142
|
+
* reader live regions. Engaging Networks owns these responses, and assistive
|
|
143
|
+
* tech doesn't reliably announce content that was already in the DOM and
|
|
144
|
+
* merely flipped to visible — and in hub overlays the responses don't even
|
|
145
|
+
* exist until a submit injects them.
|
|
146
|
+
*
|
|
147
|
+
* We append a polite (`role="status"`) and an assertive (`role="alert"`) live
|
|
148
|
+
* region, hide the originals from AT, then on any childList / display / class
|
|
149
|
+
* change we debounce and announce whichever response is currently visible,
|
|
150
|
+
* preferring a terminal result over the transient "Loading" so an instant
|
|
151
|
+
* failure isn't preceded by a stray "Loading". Failures (and, for overlays,
|
|
152
|
+
* all messages) use the assertive region; everything else uses the polite one.
|
|
153
|
+
*
|
|
154
|
+
* This is a form-agnostic engine: callers supply their own side effects via
|
|
155
|
+
* `onResult` rather than this method knowing about any specific form.
|
|
156
|
+
*
|
|
157
|
+
* @param container The element that holds (or will hold) the responses.
|
|
158
|
+
* @param options `preferAssertive` announces success assertively too —
|
|
159
|
+
* overlays redraw their content (and shift focus) on success,
|
|
160
|
+
* which makes screen readers drop polite announcements, so
|
|
161
|
+
* assertive ones are used to survive that. `onResult` is fired
|
|
162
|
+
* with each settled result (and the assertive region's id) so
|
|
163
|
+
* callers can react — e.g. the login form flags its email
|
|
164
|
+
* field invalid — without baking that into the engine.
|
|
165
|
+
*/
|
|
166
|
+
announceHubResponses(container, options = {}) {
|
|
167
|
+
var _a;
|
|
168
|
+
const { preferAssertive = false, onResult } = options;
|
|
169
|
+
// Guard against wiring the same container up twice.
|
|
170
|
+
if (container.dataset.engridResponseAnnounce === "true")
|
|
171
|
+
return;
|
|
172
|
+
container.dataset.engridResponseAnnounce = "true";
|
|
173
|
+
// The overlay popup is an `aria-modal="true"` dialog, which tells assistive
|
|
174
|
+
// tech to ignore everything outside it. Live regions appended to the outer
|
|
175
|
+
// .en__hubOverlay (a sibling of the dialog) are therefore never announced.
|
|
176
|
+
// Anchor the regions inside the dialog so they live within the modal scope.
|
|
177
|
+
// Falls back to the container itself for the non-modal login form.
|
|
178
|
+
const regionAnchor = (_a = container.querySelector(".en__hubOverlay__popup")) !== null && _a !== void 0 ? _a : container;
|
|
179
|
+
const makeRegion = (assertive) => {
|
|
180
|
+
const region = document.createElement("div");
|
|
181
|
+
region.setAttribute("role", assertive ? "alert" : "status");
|
|
182
|
+
region.setAttribute("aria-atomic", "true");
|
|
183
|
+
region.classList.add("engrid__sr-only");
|
|
184
|
+
return regionAnchor.appendChild(region);
|
|
185
|
+
};
|
|
186
|
+
const politeRegion = makeRegion(false);
|
|
187
|
+
const assertiveRegion = makeRegion(true);
|
|
188
|
+
assertiveRegion.id = `en__hubgadget__response--failure-alert-${Math.random()
|
|
189
|
+
.toString(36)
|
|
190
|
+
.slice(2, 7)}`;
|
|
191
|
+
const isVisible = (el) => window.getComputedStyle(el).display !== "none";
|
|
192
|
+
// The live regions own announcements; hide the originals so each message is
|
|
193
|
+
// read once rather than twice. Responses can be injected after the fact
|
|
194
|
+
// (overlays), so re-hide on every pass, but only write when needed to avoid
|
|
195
|
+
// retriggering our own observer.
|
|
196
|
+
const hideResponses = () => {
|
|
197
|
+
container
|
|
198
|
+
.querySelectorAll(".en__hubgadget__response")
|
|
199
|
+
.forEach((r) => {
|
|
200
|
+
if (r.getAttribute("aria-hidden") !== "true") {
|
|
201
|
+
r.setAttribute("aria-hidden", "true");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
// Clear then set on the next frame so the screen reader registers a fresh
|
|
206
|
+
// change even when the same message is re-stated after a content redraw.
|
|
207
|
+
const speak = (region, text) => {
|
|
208
|
+
region.textContent = "";
|
|
209
|
+
window.requestAnimationFrame(() => {
|
|
210
|
+
region.textContent = text;
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
let lastAnnounced = "";
|
|
214
|
+
// Set when a response node is added/removed (i.e. the overlay redrew its
|
|
215
|
+
// content). A polite announcement made mid-redraw gets dropped, so we must
|
|
216
|
+
// re-state the message once things settle even if the text is unchanged.
|
|
217
|
+
let forceReannounce = false;
|
|
218
|
+
const announce = () => {
|
|
219
|
+
var _a;
|
|
220
|
+
hideResponses();
|
|
221
|
+
const responses = Array.from(container.querySelectorAll(".en__hubgadget__response"));
|
|
222
|
+
const visible = responses.filter(isVisible);
|
|
223
|
+
const target = (_a = visible.find((r) => !r.classList.contains("en__hubgadget__response--loading"))) !== null && _a !== void 0 ? _a : visible[0];
|
|
224
|
+
const message = target ? (target.textContent || "").trim() : "";
|
|
225
|
+
const isFailure = !!(target === null || target === void 0 ? void 0 : target.classList.contains("en__hubgadget__response--failure"));
|
|
226
|
+
const reannounce = forceReannounce;
|
|
227
|
+
forceReannounce = false;
|
|
228
|
+
if (message === lastAnnounced && !reannounce)
|
|
229
|
+
return;
|
|
230
|
+
lastAnnounced = message;
|
|
231
|
+
if (!message) {
|
|
232
|
+
politeRegion.textContent = "";
|
|
233
|
+
assertiveRegion.textContent = "";
|
|
234
|
+
onResult === null || onResult === void 0 ? void 0 : onResult(false, assertiveRegion.id);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.logger.log(`Announcing hub response: ${message}`);
|
|
238
|
+
// Overlays must announce assertively (polite is dropped on their content
|
|
239
|
+
// redraw); the login form keeps polite for non-failures.
|
|
240
|
+
const useAssertive = isFailure || preferAssertive;
|
|
241
|
+
if (useAssertive) {
|
|
242
|
+
politeRegion.textContent = "";
|
|
243
|
+
speak(assertiveRegion, message);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
assertiveRegion.textContent = "";
|
|
247
|
+
speak(politeRegion, message);
|
|
248
|
+
}
|
|
249
|
+
onResult === null || onResult === void 0 ? void 0 : onResult(isFailure, assertiveRegion.id);
|
|
250
|
+
};
|
|
251
|
+
// Only react to mutations that actually involve a response. The container
|
|
252
|
+
// (especially an overlay) is full of unrelated churn — form fields, the
|
|
253
|
+
// loading spinner, the a11y error regions — and our own live-region writes
|
|
254
|
+
// are children of it too. Reacting to all of that would perpetually reset
|
|
255
|
+
// the debounce and drown out the real announcement.
|
|
256
|
+
const isResponse = (node) => node instanceof HTMLElement &&
|
|
257
|
+
(node.classList.contains("en__hubgadget__response") ||
|
|
258
|
+
!!node.querySelector(".en__hubgadget__response"));
|
|
259
|
+
const isRelevant = (record) => {
|
|
260
|
+
if (record.type === "childList") {
|
|
261
|
+
return (Array.from(record.addedNodes).some(isResponse) ||
|
|
262
|
+
Array.from(record.removedNodes).some(isResponse));
|
|
263
|
+
}
|
|
264
|
+
// attribute (style/class) change directly on a response element
|
|
265
|
+
return (record.target instanceof HTMLElement &&
|
|
266
|
+
record.target.classList.contains("en__hubgadget__response"));
|
|
267
|
+
};
|
|
268
|
+
let debounce = 0;
|
|
269
|
+
new MutationObserver((records) => {
|
|
270
|
+
const relevant = records.filter(isRelevant);
|
|
271
|
+
if (!relevant.length)
|
|
272
|
+
return;
|
|
273
|
+
// A response node being added/removed means the content was redrawn;
|
|
274
|
+
// force a re-announcement so a message dropped mid-redraw is re-stated.
|
|
275
|
+
if (relevant.some((r) => r.type === "childList")) {
|
|
276
|
+
forceReannounce = true;
|
|
277
|
+
}
|
|
278
|
+
window.clearTimeout(debounce);
|
|
279
|
+
debounce = window.setTimeout(announce, 250);
|
|
280
|
+
}).observe(container, {
|
|
281
|
+
attributes: true,
|
|
282
|
+
attributeFilter: ["style", "class"],
|
|
283
|
+
childList: true,
|
|
284
|
+
subtree: true,
|
|
285
|
+
});
|
|
286
|
+
announce(); // catch any response already visible at setup
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Run the field-level accessibility scan and response announcer against a
|
|
290
|
+
* freshly opened overlay. EN injects overlay markup (and its fields) when the
|
|
291
|
+
* gadget is clicked, so A11y's constructor-time sweep never sees them. The
|
|
292
|
+
* scan is idempotent and the announcer guards against double-wiring, so a
|
|
293
|
+
* delayed pass — matching the timing of the other overlay handlers — is safe.
|
|
294
|
+
*/
|
|
295
|
+
accessibilityScan(overlay) {
|
|
296
|
+
window.setTimeout(() => {
|
|
297
|
+
A11y.scanFields(overlay);
|
|
298
|
+
this.announceHubResponses(overlay, { preferAssertive: true });
|
|
299
|
+
}, 300);
|
|
89
300
|
}
|
|
90
301
|
creditCardUpdate(overlay) {
|
|
91
302
|
window.setTimeout(() => {
|
|
@@ -115,12 +326,22 @@ export class SupporterHub {
|
|
|
115
326
|
}
|
|
116
327
|
dialogAltsAndArias(overlay) {
|
|
117
328
|
window.setTimeout(() => {
|
|
118
|
-
|
|
329
|
+
const hubOverlay = overlay.classList.contains("en__hubOverlay")
|
|
330
|
+
? overlay
|
|
331
|
+
: document.querySelector(".en__hubOverlay") ||
|
|
332
|
+
overlay;
|
|
333
|
+
A11y.inertPage(true, hubOverlay);
|
|
119
334
|
const header = overlay.querySelector(".en__hubOverlay__header"), closeButton = header.querySelector("a");
|
|
120
335
|
// Tag close button
|
|
121
336
|
if (header && closeButton) {
|
|
122
337
|
closeButton.setAttribute("role", "button");
|
|
123
338
|
closeButton.setAttribute("aria-label", "Close");
|
|
339
|
+
document.addEventListener("keydown", (e) => {
|
|
340
|
+
if (e.key === "Escape") {
|
|
341
|
+
this.logger.log("Escape key pressed, closing overlay");
|
|
342
|
+
closeButton.click();
|
|
343
|
+
}
|
|
344
|
+
}, { once: true });
|
|
124
345
|
}
|
|
125
346
|
// Tag header and label dialog
|
|
126
347
|
const headerTitle = header.querySelector("h2");
|
|
@@ -144,39 +365,6 @@ export class SupporterHub {
|
|
|
144
365
|
}
|
|
145
366
|
}, 300);
|
|
146
367
|
}
|
|
147
|
-
inertPage(inert, overlay) {
|
|
148
|
-
if (inert) {
|
|
149
|
-
const hubOverlay = overlay && overlay.classList.contains("en__hubOverlay")
|
|
150
|
-
? overlay
|
|
151
|
-
: document.querySelector(".en__hubOverlay") ||
|
|
152
|
-
overlay;
|
|
153
|
-
if (!hubOverlay)
|
|
154
|
-
return;
|
|
155
|
-
let element = hubOverlay;
|
|
156
|
-
while (element && element !== document.body) {
|
|
157
|
-
const parent = element.parentElement;
|
|
158
|
-
if (parent) {
|
|
159
|
-
Array.from(parent.children).forEach((sibling) => {
|
|
160
|
-
if (sibling !== element &&
|
|
161
|
-
sibling instanceof HTMLElement &&
|
|
162
|
-
!sibling.hasAttribute("inert")) {
|
|
163
|
-
sibling.setAttribute("inert", "");
|
|
164
|
-
sibling.dataset.engridInert = "true";
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
element = parent;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
else if (!document.querySelector(".en__hubOverlay, .en__hubPledge__panels")) {
|
|
172
|
-
document
|
|
173
|
-
.querySelectorAll("[data-engrid-inert]")
|
|
174
|
-
.forEach((element) => {
|
|
175
|
-
element.removeAttribute("inert");
|
|
176
|
-
delete element.dataset.engridInert;
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
368
|
// The supporter hub does not properly handle or prevent duplicate submits, so we add a listener to prevent this.
|
|
181
369
|
preventDuplicateSubmits() {
|
|
182
370
|
document.addEventListener("click", (e) => {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const AppVersion = "0.25.
|
|
1
|
+
export declare const AppVersion = "0.25.11";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const AppVersion = "0.25.
|
|
1
|
+
export const AppVersion = "0.25.11";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4site/engrid-scripts",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.11",
|
|
4
4
|
"description": "Scripts for the ENGRID Project",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"strongly-typed-events": "^2.0.9"
|
|
26
26
|
},
|
|
27
27
|
"homepage": "https://github.com/4site-interactive-studios/engrid#readme",
|
|
28
|
-
"gitHead": "
|
|
28
|
+
"gitHead": "ab9e0c5cfefa3568c81101134ae40110939ae874",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"nodemon": "^2.0.20"
|
|
31
31
|
}
|