@4site/engrid-scripts 0.25.9 → 0.25.12

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
  }
@@ -50,7 +50,10 @@ export interface Options {
50
50
  fieldOptInSelectorTargetLocation?: string;
51
51
  fieldClearSelectorTarget?: string;
52
52
  fieldClearSelectorTargetLocation?: string;
53
+ fieldClearLabel?: string;
53
54
  checked?: boolean;
55
+ encryptData?: boolean;
56
+ hide?: boolean;
54
57
  };
55
58
  StickyNSG?: boolean;
56
59
  StickyPrepopulation?: false | {
@@ -2,6 +2,7 @@ import { EnForm, RememberMeEvents } from "./events";
2
2
  export declare class RememberMe {
3
3
  _form: EnForm;
4
4
  _events: RememberMeEvents;
5
+ private _frequency;
5
6
  private remoteUrl;
6
7
  private cookieName;
7
8
  private fieldNames;
@@ -9,14 +10,18 @@ export declare class RememberMe {
9
10
  private cookieExpirationDays;
10
11
  private iframe;
11
12
  private rememberMeOptIn;
13
+ private encryptData;
14
+ private hide;
12
15
  private fieldDonationAmountRadioName;
13
16
  private fieldDonationAmountOtherName;
14
17
  private fieldDonationRecurrPayRadioName;
18
+ private fieldDonationRecurrFreqRadioName;
15
19
  private fieldDonationAmountOtherCheckboxID;
16
20
  private fieldOptInSelectorTarget;
17
21
  private fieldOptInSelectorTargetLocation;
18
22
  private fieldClearSelectorTarget;
19
23
  private fieldClearSelectorTargetLocation;
24
+ private fieldClearLabel;
20
25
  constructor(options: {
21
26
  remoteUrl?: string;
22
27
  cookieName?: string;
@@ -25,12 +30,16 @@ export declare class RememberMe {
25
30
  fieldDonationAmountRadioName?: string;
26
31
  fieldDonationAmountOtherName?: string;
27
32
  fieldDonationRecurrPayRadioName?: string;
33
+ fieldDonationRecurrFreqRadioName?: string;
28
34
  fieldDonationAmountOtherCheckboxID?: string;
29
35
  fieldOptInSelectorTarget?: string;
30
36
  fieldOptInSelectorTargetLocation?: string;
31
37
  fieldClearSelectorTarget?: string;
32
38
  fieldClearSelectorTargetLocation?: string;
39
+ fieldClearLabel?: string;
33
40
  checked?: boolean;
41
+ encryptData?: boolean;
42
+ hide?: boolean;
34
43
  });
35
44
  private updateFieldData;
36
45
  private insertClearRememberMeLink;
@@ -43,6 +52,42 @@ export declare class RememberMe {
43
52
  private saveCookieToRemote;
44
53
  private readCookie;
45
54
  private saveCookie;
55
+ /**
56
+ * Reads and decrypts the local (non-remote) Remember Me cookie using
57
+ * browser-native AES-GCM (Web Crypto), with the key held in localStorage
58
+ * on this device. If the key is absent (different device or cleared
59
+ * storage) or decryption otherwise fails, the field data is left empty
60
+ * and the component falls back to the normal, no-autofill experience.
61
+ */
62
+ private readCookieEncrypted;
63
+ /**
64
+ * Encrypts the current fieldData with AES-GCM (Web Crypto) and stores the
65
+ * base64-encoded result in the local cookie. If encryption isn't possible
66
+ * (e.g. Web Crypto unavailable), nothing is written.
67
+ */
68
+ private saveCookieEncrypted;
69
+ private clearCookieEncrypted;
70
+ /**
71
+ * Retrieves the per-device AES-GCM encryption key. A random secret
72
+ * generated once per device and held in localStorage — never written
73
+ * to the cookie, so it never travels with the transported value.
74
+ */
75
+ private getEncryptionKey;
76
+ /**
77
+ * Encrypts a plaintext string with AES-GCM and returns the base64-encoded
78
+ * IV + ciphertext, ready for storage. Returns null if a key isn't
79
+ * available (e.g. Web Crypto unsupported).
80
+ */
81
+ private encryptPayload;
82
+ /**
83
+ * Decrypts a base64-encoded IV + ciphertext payload previously produced by
84
+ * encryptPayload. Returns null (rather than throwing) if the key is
85
+ * missing or decryption otherwise fails, so callers can gracefully fall
86
+ * back to the standard, no-autofill experience.
87
+ */
88
+ private decryptPayload;
89
+ private arrayBufferToBase64;
90
+ private base64ToArrayBuffer;
46
91
  private readFields;
47
92
  private setFieldValue;
48
93
  private clearFields;
@@ -57,5 +102,25 @@ export declare class RememberMe {
57
102
  * @param overwrite - A boolean indicating whether to overwrite the existing value of the fields. Defaults to false.
58
103
  */
59
104
  private writeFields;
105
+ /**
106
+ * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page
107
+ * load (triggered by DonationFrequency.load() setTimeout). When that happens
108
+ * the selection the RememberMe just wrote gets wiped out.
109
+ *
110
+ * This method subscribes to the first onFrequencyChange event and, after a
111
+ * short delay to let SwapAmounts finish its DOM update, re-applies only the
112
+ * donation amount. It unsubscribes immediately so it only fires once.
113
+ *
114
+ * To avoid overwriting a manual donor interaction, the handler checks
115
+ * whether the current amount selection is empty/wiped (as SwapAmounts does)
116
+ * OR still matches what writeFields originally set. If the donor already
117
+ * picked a different amount, we skip re-application.
118
+ */
119
+ private reapplyDonationAmtAfterSwap;
120
+ /**
121
+ * Returns the currently selected donation amount value, or null if nothing
122
+ * is selected. Checks both predefined radio buttons and the "Other" text input.
123
+ */
124
+ private getCurrentSelectedAmount;
60
125
  private isJson;
61
126
  }