@4site/engrid-scripts 0.27.2 → 0.27.3

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
@@ -1,9 +1,11 @@
1
1
  export declare class A11y {
2
2
  private logger;
3
3
  private observer;
4
+ private liveRegionUpdateTimeout;
5
+ private shouldFocusFirstInvalidField;
4
6
  constructor();
5
7
  /**
6
- * Apply the field-level accessibility tagging (error alert live regions,
8
+ * Apply the field-level accessibility tagging (error containers,
7
9
  * aria-required, fallback aria-labels, radio group roles) to every field
8
10
  * within `root`. Defaults to the whole document on initial load, but can be
9
11
  * pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
@@ -11,22 +13,23 @@ export declare class A11y {
11
13
  * idempotent, so re-scanning already tagged fields is safe.
12
14
  */
13
15
  static scanFields(root?: ParentNode): void;
14
- private static addErrorAlertArea;
16
+ private static addGlobalErrorLiveRegion;
15
17
  private static addGroupRole;
16
18
  private static addRequired;
17
19
  private static addLabel;
18
20
  private updateFrequencyLabel;
19
21
  private setAutoGeneratedAltTags;
20
22
  /**
21
- * Observe #engrid for .en__field__error additions and removals, mirroring
22
- * text into the per-field .en__field__error__alert live region and toggling
23
- * aria-invalid / aria-describedby on the corresponding input. Runs for the
24
- * lifetime of the page so async validators (NeverBounce, VGS, server
25
- * re-renders) are caught without timing assumptions.
23
+ * Observe #engrid for .en__field__error additions and removals, relocating
24
+ * each error after its field's input so DOM order matches visual order. Also
25
+ * toggles aria-invalid / aria-describedby on the corresponding input (or radio
26
+ * group when the field has role="group") and updates a single, DOM-ordered
27
+ * global live region. Runs for the lifetime of the page so async validators
28
+ * (NeverBounce, VGS, server re-renders) are caught without timing assumptions.
26
29
  */
27
30
  private observeErrorMessages;
28
- private moveErrorMessage;
29
- private clearErrorMessage;
31
+ private tagFieldError;
32
+ private clearFieldError;
30
33
  /**
31
34
  * Make everything on the page inert except the supplied overlay element and
32
35
  * its ancestors. This hides background content from assistive technology and
@@ -40,5 +43,10 @@ export declare class A11y {
40
43
  * `inert` is true; ignored when `inert` is false.
41
44
  */
42
45
  static inertPage(inert: boolean, overlay?: HTMLElement): void;
43
- private manageErrorListAlertRole;
46
+ private scheduleLiveRegionUpdate;
47
+ private updateGlobalErrorLiveRegion;
48
+ private formatErrorMessage;
49
+ private focusFirstInvalidField;
50
+ private getFieldLabel;
51
+ private normalizeLabel;
44
52
  }
package/dist/a11y.js CHANGED
@@ -1,19 +1,32 @@
1
1
  import { EngridLogger } from "./logger";
2
+ import { ENGrid } from "./engrid";
3
+ import { EnForm } from "./events/en-form";
2
4
  // a11y means accessibility
3
5
  // This Component is supposed to be used as a helper for Aria Attributes & Other Accessibility Features
4
6
  export class A11y {
5
7
  constructor() {
8
+ var _a;
6
9
  this.logger = new EngridLogger("A11y", "#FFFFFF", "#811212", "👁️‍🗨️");
7
10
  this.observer = null;
11
+ this.liveRegionUpdateTimeout = null;
12
+ this.shouldFocusFirstInvalidField = false;
8
13
  A11y.scanFields();
9
14
  this.updateFrequencyLabel();
10
15
  const ecardImages = document.querySelectorAll('.en__ecarditems__list img');
11
16
  this.setAutoGeneratedAltTags(ecardImages);
12
- this.manageErrorListAlertRole();
13
17
  this.observeErrorMessages();
18
+ (_a = ENGrid.enForm) === null || _a === void 0 ? void 0 : _a.addEventListener('submit', () => {
19
+ this.shouldFocusFirstInvalidField = true;
20
+ });
21
+ // onSubmit only fires once validation has passed, so disarm the focus
22
+ // flag: a successful submit must not leave it set, or the next unrelated
23
+ // async field error (e.g. NeverBounce on blur) would steal focus.
24
+ EnForm.getInstance().onSubmit.subscribe(() => {
25
+ this.shouldFocusFirstInvalidField = false;
26
+ });
14
27
  }
15
28
  /**
16
- * Apply the field-level accessibility tagging (error alert live regions,
29
+ * Apply the field-level accessibility tagging (error containers,
17
30
  * aria-required, fallback aria-labels, radio group roles) to every field
18
31
  * within `root`. Defaults to the whole document on initial load, but can be
19
32
  * pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
@@ -21,24 +34,29 @@ export class A11y {
21
34
  * idempotent, so re-scanning already tagged fields is safe.
22
35
  */
23
36
  static scanFields(root = document) {
24
- A11y.addErrorAlertArea(root);
37
+ A11y.addGlobalErrorLiveRegion(root);
25
38
  A11y.addRequired(root);
26
39
  A11y.addLabel(root);
27
40
  A11y.addGroupRole(root);
28
41
  }
29
- static addErrorAlertArea(root = document) {
30
- const fieldElements = root.querySelectorAll('.en__field .en__field__element');
31
- fieldElements.forEach((fieldElement) => {
32
- const fieldWrapper = fieldElement.closest('.en__field');
33
- if (fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert'))
34
- return;
35
- const errorAlert = document.createElement('div');
36
- errorAlert.setAttribute('aria-live', 'polite');
37
- errorAlert.setAttribute('aria-atomic', 'true');
38
- errorAlert.classList.add('en__field__error__alert');
39
- errorAlert.id = `en__field__error__alert--${Math.random().toString(36).slice(2, 7)}`;
40
- fieldElement.insertAdjacentElement('afterend', errorAlert);
41
- });
42
+ static addGlobalErrorLiveRegion(root = document) {
43
+ var _a;
44
+ const liveRegionId = 'engrid-a11y-error-summary';
45
+ if (document.getElementById(liveRegionId))
46
+ return;
47
+ const form = (_a = ENGrid.enForm) !== null && _a !== void 0 ? _a : root.querySelector('form.en__component');
48
+ if (!form)
49
+ return;
50
+ const region = document.createElement('div');
51
+ region.id = liveRegionId;
52
+ region.className = 'engrid__sr-only';
53
+ region.setAttribute('aria-live', 'polite');
54
+ region.setAttribute('aria-atomic', 'true');
55
+ form.insertBefore(region, form.firstChild);
56
+ const errorList = document.querySelector('ul.en__errorList');
57
+ if (errorList === null || errorList === void 0 ? void 0 : errorList.hasAttribute('role')) {
58
+ errorList.removeAttribute('role');
59
+ }
42
60
  }
43
61
  static addGroupRole(root = document) {
44
62
  // Add role="group" to all EN Radio fields
@@ -52,6 +70,13 @@ export class A11y {
52
70
  const label = field.querySelector("label");
53
71
  if (label) {
54
72
  label.setAttribute("id", `en__field__label--${Math.random().toString(36).slice(2, 7)}`);
73
+ // EN renders the group's question label as a <label>, but a radio group
74
+ // has no single control for `for` to point at. When EN leaves it blank
75
+ // (`for=""`) it's an invalid IDREF, so strip only that empty case
76
+ const forAttr = label.getAttribute("for");
77
+ if (forAttr !== null && forAttr.trim() === "") {
78
+ label.removeAttribute("for");
79
+ }
55
80
  field.setAttribute("aria-labelledby", label.id);
56
81
  }
57
82
  });
@@ -128,11 +153,12 @@ export class A11y {
128
153
  });
129
154
  }
130
155
  /**
131
- * Observe #engrid for .en__field__error additions and removals, mirroring
132
- * text into the per-field .en__field__error__alert live region and toggling
133
- * aria-invalid / aria-describedby on the corresponding input. Runs for the
134
- * lifetime of the page so async validators (NeverBounce, VGS, server
135
- * re-renders) are caught without timing assumptions.
156
+ * Observe #engrid for .en__field__error additions and removals, relocating
157
+ * each error after its field's input so DOM order matches visual order. Also
158
+ * toggles aria-invalid / aria-describedby on the corresponding input (or radio
159
+ * group when the field has role="group") and updates a single, DOM-ordered
160
+ * global live region. Runs for the lifetime of the page so async validators
161
+ * (NeverBounce, VGS, server re-renders) are caught without timing assumptions.
136
162
  */
137
163
  observeErrorMessages() {
138
164
  var _a;
@@ -143,22 +169,22 @@ export class A11y {
143
169
  continue;
144
170
  record.addedNodes.forEach(node => {
145
171
  if (node instanceof HTMLElement && node.classList.contains('en__field__error')) {
146
- this.moveErrorMessage(node);
172
+ this.tagFieldError(node);
147
173
  }
148
174
  });
149
175
  record.removedNodes.forEach(node => {
150
- var _a, _b;
151
176
  if (!(node instanceof HTMLElement))
152
177
  return;
153
178
  if (!node.classList.contains('en__field__error'))
154
179
  return;
180
+ if (node.isConnected)
181
+ return; // relocated, not removed
155
182
  // node.parentElement is null after removal; record.target is the
156
183
  // former parent. Walk up to the enclosing .en__field to be defensive
157
184
  // against deeper nesting.
158
- const fieldWrapper = (_b = (_a = record.target).closest) === null || _b === void 0 ? void 0 : _b.call(_a, '.en__field');
159
- const alert = fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert');
160
- if (alert)
161
- this.clearErrorMessage(alert);
185
+ const fieldWrapper = record.target.closest('.en__field');
186
+ if (fieldWrapper)
187
+ this.clearFieldError(fieldWrapper, node.id);
162
188
  });
163
189
  }
164
190
  });
@@ -167,55 +193,76 @@ export class A11y {
167
193
  // before this observer was attached.
168
194
  document.querySelectorAll('.en__field').forEach(field => {
169
195
  const error = field.querySelector('.en__field__error');
170
- const alert = field.querySelector('.en__field__error__alert');
171
196
  if (error)
172
- this.moveErrorMessage(error);
173
- else if (alert)
174
- this.clearErrorMessage(alert);
197
+ this.tagFieldError(error);
175
198
  });
176
199
  }
177
- moveErrorMessage(field) {
200
+ tagFieldError(error) {
178
201
  var _a;
179
- if (field.closest('.en__field__error__alert'))
180
- return;
181
- const fieldWrapper = field.closest('.en__field');
202
+ const fieldWrapper = error.closest('.en__field');
182
203
  if (!fieldWrapper)
183
204
  return;
184
- const alertContainer = fieldWrapper.querySelector('.en__field__error__alert');
185
- if (!alertContainer)
186
- return;
187
- alertContainer.textContent = field.textContent;
188
- field.classList.add('en__field__error--hidden-by-a11y');
189
- const inputElement = fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
190
- if (!inputElement)
205
+ const fieldElement = fieldWrapper.querySelector('.en__field__element');
206
+ if (fieldElement && fieldElement.nextElementSibling !== error) {
207
+ fieldElement.insertAdjacentElement('afterend', error);
208
+ }
209
+ if (!error.id) {
210
+ error.id = `en__field__error--a11y-${Math.random().toString(36).slice(2, 7)}`;
211
+ }
212
+ this.scheduleLiveRegionUpdate();
213
+ const target = fieldWrapper.getAttribute('role') === 'group'
214
+ ? fieldWrapper
215
+ : fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
216
+ if (!target)
191
217
  return;
192
- inputElement.setAttribute('aria-invalid', 'true');
193
- const describedBy = ((_a = inputElement.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
218
+ target.setAttribute('aria-invalid', 'true');
219
+ const describedBy = ((_a = target.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
194
220
  .split(/\s+/)
195
221
  .filter(Boolean);
196
- if (describedBy.indexOf(alertContainer.id) === -1) {
197
- describedBy.push(alertContainer.id);
222
+ if (describedBy.indexOf(error.id) === -1) {
223
+ describedBy.push(error.id);
224
+ }
225
+ target.setAttribute('aria-describedby', describedBy.join(' '));
226
+ // For radio groups, the group itself is the accessible widget; keep the
227
+ // individual inputs from also being announced as invalid.
228
+ if (target === fieldWrapper) {
229
+ fieldWrapper
230
+ .querySelectorAll('.en__field__element input, .en__field__element select, .en__field__element textarea')
231
+ .forEach(input => {
232
+ var _a;
233
+ input.removeAttribute('aria-invalid');
234
+ const remaining = ((_a = input.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
235
+ .split(/\s+/)
236
+ .filter(id => id && id !== error.id);
237
+ if (remaining.length) {
238
+ input.setAttribute('aria-describedby', remaining.join(' '));
239
+ }
240
+ else {
241
+ input.removeAttribute('aria-describedby');
242
+ }
243
+ });
198
244
  }
199
- inputElement.setAttribute('aria-describedby', describedBy.join(' '));
200
245
  }
201
- clearErrorMessage(alert) {
246
+ clearFieldError(fieldWrapper, errorId) {
202
247
  var _a;
203
- alert.textContent = '';
204
- const fieldWrapper = alert.closest('.en__field');
205
- if (!fieldWrapper)
206
- return;
207
- const inputElement = fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
208
- if (!inputElement)
248
+ this.scheduleLiveRegionUpdate();
249
+ const target = fieldWrapper.getAttribute('role') === 'group'
250
+ ? fieldWrapper
251
+ : fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
252
+ if (!target)
209
253
  return;
210
- inputElement.removeAttribute('aria-invalid');
211
- const remaining = ((_a = inputElement.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
254
+ const hasRemainingError = fieldWrapper.querySelector('.en__field__error') !== null;
255
+ if (!hasRemainingError) {
256
+ target.removeAttribute('aria-invalid');
257
+ }
258
+ const remaining = ((_a = target.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
212
259
  .split(/\s+/)
213
- .filter(id => id && id !== alert.id);
260
+ .filter(id => id && id !== errorId);
214
261
  if (remaining.length) {
215
- inputElement.setAttribute('aria-describedby', remaining.join(' '));
262
+ target.setAttribute('aria-describedby', remaining.join(' '));
216
263
  }
217
264
  else {
218
- inputElement.removeAttribute('aria-describedby');
265
+ target.removeAttribute('aria-describedby');
219
266
  }
220
267
  }
221
268
  /**
@@ -259,29 +306,108 @@ export class A11y {
259
306
  });
260
307
  }
261
308
  }
262
- manageErrorListAlertRole() {
309
+ scheduleLiveRegionUpdate() {
310
+ if (this.liveRegionUpdateTimeout !== null) {
311
+ window.clearTimeout(this.liveRegionUpdateTimeout);
312
+ }
313
+ this.liveRegionUpdateTimeout = window.setTimeout(() => {
314
+ this.liveRegionUpdateTimeout = null;
315
+ this.updateGlobalErrorLiveRegion();
316
+ }, 100);
317
+ }
318
+ updateGlobalErrorLiveRegion() {
319
+ var _a;
320
+ const region = document.getElementById('engrid-a11y-error-summary');
321
+ if (!region)
322
+ return;
263
323
  const errorList = document.querySelector('ul.en__errorList');
264
- if (!errorList)
324
+ if (errorList === null || errorList === void 0 ? void 0 : errorList.hasAttribute('role')) {
325
+ errorList.removeAttribute('role');
326
+ }
327
+ const fields = Array.from(document.querySelectorAll('.en__field'));
328
+ const messages = fields
329
+ .map(field => {
330
+ var _a;
331
+ const error = field.querySelector('.en__field__error');
332
+ if (!((_a = error === null || error === void 0 ? void 0 : error.textContent) === null || _a === void 0 ? void 0 : _a.trim()))
333
+ return null;
334
+ const label = this.getFieldLabel(field);
335
+ const message = this.formatErrorMessage(label, error.textContent.trim());
336
+ return message;
337
+ })
338
+ .filter((message) => Boolean(message));
339
+ // Top-of-form server errors (ul.en__errorList) aren't tied to a .en__field,
340
+ // so fold in any that aren't already covered by the per-field messages,
341
+ // otherwise they would be silent for screen readers.
342
+ const normalizeForCompare = (value) => value.replace(/\s+/g, ' ').replace(/[.!?:]+$/, '').trim().toLowerCase();
343
+ const coveredMessages = messages.map(normalizeForCompare).filter(Boolean);
344
+ const serverMessages = Array.from((_a = errorList === null || errorList === void 0 ? void 0 : errorList.querySelectorAll('li')) !== null && _a !== void 0 ? _a : [])
345
+ .map(item => { var _a, _b; return (_b = (_a = item.textContent) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ''; })
346
+ .filter(text => {
347
+ if (!text)
348
+ return false;
349
+ const normalized = normalizeForCompare(text);
350
+ if (!normalized)
351
+ return false;
352
+ return !coveredMessages.some(covered => covered === normalized ||
353
+ covered.includes(normalized) ||
354
+ normalized.includes(covered));
355
+ });
356
+ const allMessages = [...messages, ...serverMessages];
357
+ region.textContent = '';
358
+ if (!allMessages.length) {
359
+ this.shouldFocusFirstInvalidField = false;
265
360
  return;
266
- const hasErrorItems = () => Boolean(errorList.querySelector('li'));
267
- const enableAlert = () => {
268
- if (!errorList.hasAttribute('role')) {
269
- errorList.setAttribute('role', 'alert');
270
- }
271
- };
272
- const disableAlert = () => {
273
- if (errorList.hasAttribute('role')) {
274
- errorList.removeAttribute('role');
275
- }
276
- };
277
- hasErrorItems() ? enableAlert() : disableAlert();
278
- new MutationObserver(records => {
279
- for (const record of records) {
280
- if (record.type === 'childList') {
281
- hasErrorItems() ? enableAlert() : disableAlert();
282
- break;
283
- }
361
+ }
362
+ if (allMessages.length === 1) {
363
+ region.textContent = allMessages[0];
364
+ }
365
+ else {
366
+ const cleaned = allMessages.map(message => message.replace(/[.!?]+$/, '').trim());
367
+ region.textContent = `There are ${allMessages.length} errors: ${cleaned.join('. ')}.`;
368
+ }
369
+ if (this.shouldFocusFirstInvalidField) {
370
+ this.shouldFocusFirstInvalidField = false;
371
+ this.focusFirstInvalidField();
372
+ }
373
+ }
374
+ formatErrorMessage(label, message) {
375
+ const cleanMessage = message.trim();
376
+ const cleanLabel = this.normalizeLabel(label);
377
+ if (!cleanLabel)
378
+ return cleanMessage;
379
+ // If the message already mentions the field label, no need to prefix it.
380
+ const escapedLabel = cleanLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
381
+ const labelRegex = new RegExp(`\\b${escapedLabel}\\b`, 'i');
382
+ if (labelRegex.test(cleanMessage))
383
+ return cleanMessage;
384
+ return `${cleanLabel}: ${cleanMessage}`;
385
+ }
386
+ focusFirstInvalidField() {
387
+ const fields = Array.from(document.querySelectorAll('.en__field'));
388
+ for (const field of fields) {
389
+ if (!field.querySelector('.en__field__error'))
390
+ continue;
391
+ const target = field.querySelector('.en__field__element input:not([type="hidden"]), .en__field__element select, .en__field__element textarea');
392
+ if (target && target.offsetParent !== null && !target.hasAttribute('disabled')) {
393
+ target.focus();
394
+ return;
284
395
  }
285
- }).observe(errorList, { childList: true });
396
+ }
397
+ }
398
+ getFieldLabel(field) {
399
+ var _a, _b, _c, _d, _e, _f, _g;
400
+ const label = (_c = (_b = (_a = field.querySelector('.en__field__label:not(.en__field__label--item)')) !== null && _a !== void 0 ? _a : field.querySelector('.en__field__label')) !== null && _b !== void 0 ? _b : field.querySelector('label')) !== null && _c !== void 0 ? _c : field.querySelector('legend');
401
+ const labelText = (_e = (_d = label === null || label === void 0 ? void 0 : label.textContent) === null || _d === void 0 ? void 0 : _d.trim()) !== null && _e !== void 0 ? _e : '';
402
+ if (labelText)
403
+ return this.normalizeLabel(labelText);
404
+ const input = field.querySelector('input, select, textarea');
405
+ return (_g = (_f = input === null || input === void 0 ? void 0 : input.getAttribute('aria-label')) === null || _f === void 0 ? void 0 : _f.trim()) !== null && _g !== void 0 ? _g : '';
406
+ }
407
+ normalizeLabel(label) {
408
+ return label
409
+ .replace(/\s+/g, ' ')
410
+ .replace(/^[*:\s]+|[*:\s]+$/g, '')
411
+ .trim();
286
412
  }
287
413
  }
@@ -1,3 +1,8 @@
1
+ declare global {
2
+ interface Window {
3
+ UseBodyBannerImageAsBackground?: boolean;
4
+ }
5
+ }
1
6
  export declare class PageBackground {
2
7
  private pageBackground;
3
8
  private bodyBanner;
@@ -7,6 +7,9 @@ export class PageBackground {
7
7
  this.bodyBannerImage = null;
8
8
  this.mutationObserver = null;
9
9
  this.logger = new EngridLogger("PageBackground", "lightblue", "darkblue", "🖼️");
10
+ if (typeof window.UseBodyBannerImageAsBackground !== "undefined") {
11
+ useBodyBannerImage = !!window.UseBodyBannerImageAsBackground;
12
+ }
10
13
  if (useBodyBannerImage) {
11
14
  this.bodyBannerImage = this.findBodyBannerImage();
12
15
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const AppVersion = "0.27.2";
1
+ export declare const AppVersion = "0.27.3";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const AppVersion = "0.27.2";
1
+ export const AppVersion = "0.27.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4site/engrid-scripts",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
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": "12d807e7827704b485c0ce1b99e81b1e957362e8",
28
+ "gitHead": "b117056008fd80443843cefd5178ad9942a59843",
29
29
  "devDependencies": {
30
30
  "nodemon": "^2.0.20"
31
31
  }