@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 CHANGED
@@ -2,10 +2,19 @@ export declare class A11y {
2
2
  private logger;
3
3
  private observer;
4
4
  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;
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
- this.addErrorAlertArea();
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
- 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');
20
31
  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'))
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 = document.querySelectorAll(".en__field--radio");
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 = document.querySelectorAll(".en__mandatory .en__field__input");
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 = document.querySelector(".en__field__input--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 = document.querySelectorAll(".en__field__input--splitselect");
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
@@ -5,4 +5,8 @@ export declare class Ecard {
5
5
  constructor();
6
6
  private shouldRun;
7
7
  private checkRecipientFields;
8
+ private altsAndArias;
9
+ private altsAndAriasEcardItemsList;
10
+ private coupleLabelAndInput;
11
+ private coupleH2AndInput;
8
12
  }
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
  }
@@ -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
  }
@@ -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(node);
32
- this.amountLabelUpdate(node);
33
- this.dialogAltsAndArias(node);
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
- this.inertPage(false);
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
- this.inertPage(true, overlay);
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.9";
1
+ export declare const AppVersion = "0.25.11";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const AppVersion = "0.25.9";
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.9",
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": "18896dd2e4e4ed893ac2531f2fc01586fd5af904",
28
+ "gitHead": "ab9e0c5cfefa3568c81101134ae40110939ae874",
29
29
  "devDependencies": {
30
30
  "nodemon": "^2.0.20"
31
31
  }