Skip to content
Merged
30 changes: 26 additions & 4 deletions packages/scripts/dist/a11y.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ export declare class A11y {
private logger;
private observer;
constructor();
private addErrorAlertArea;
private addGroupRole;
private addRequired;
private addLabel;
/**
* Apply the field-level accessibility tagging (error alert live regions,
* aria-required, fallback aria-labels, radio group roles) to every field
* within `root`. Defaults to the whole document on initial load, but can be
* pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
* dynamically added forms get the same treatment. All operations are
* idempotent, so re-scanning already tagged fields is safe.
*/
static scanFields(root?: ParentNode): void;
private static addErrorAlertArea;
private static addGroupRole;
private static addRequired;
private static addLabel;
private updateFrequencyLabel;
private setAutoGeneratedAltTags;
/**
Expand All @@ -18,5 +27,18 @@ export declare class A11y {
private observeErrorMessages;
private moveErrorMessage;
private clearErrorMessage;
/**
* Make everything on the page inert except the supplied overlay element and
* its ancestors. This hides background content from assistive technology and
* prevents focus from escaping a modal-style overlay.
*
* @param inert When true, set `inert` on all siblings of the overlay and of
* each of its ancestors. When false, remove `inert` from every
* element this method previously marked (tracked via the
* `data-engrid-inert` flag).
* @param overlay The element that should remain interactive. Required when
* `inert` is true; ignored when `inert` is false.
*/
static inertPage(inert: boolean, overlay?: HTMLElement): void;
private manageErrorListAlertRole;
}
85 changes: 70 additions & 15 deletions packages/scripts/dist/a11y.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,32 @@ export class A11y {
constructor() {
this.logger = new EngridLogger("A11y", "#FFFFFF", "#811212", "👁️‍🗨️");
this.observer = null;
this.addErrorAlertArea();
this.addRequired();
this.addLabel();
this.addGroupRole();
A11y.scanFields();
this.updateFrequencyLabel();
const ecardImages = document.querySelectorAll('.en__ecarditems__list img');
this.setAutoGeneratedAltTags(ecardImages);
this.manageErrorListAlertRole();
this.observeErrorMessages();
}
addErrorAlertArea() {
const fieldElements = document.querySelectorAll('.en__field .en__field__element');
/**
* Apply the field-level accessibility tagging (error alert live regions,
* aria-required, fallback aria-labels, radio group roles) to every field
* within `root`. Defaults to the whole document on initial load, but can be
* pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
* dynamically added forms get the same treatment. All operations are
* idempotent, so re-scanning already tagged fields is safe.
*/
static scanFields(root = document) {
A11y.addErrorAlertArea(root);
A11y.addRequired(root);
A11y.addLabel(root);
A11y.addGroupRole(root);
}
static addErrorAlertArea(root = document) {
const fieldElements = root.querySelectorAll('.en__field .en__field__element');
fieldElements.forEach((fieldElement) => {
var _a;
if ((_a = fieldElement.nextElementSibling) === null || _a === void 0 ? void 0 : _a.classList.contains('en__field__error__alert'))
const fieldWrapper = fieldElement.closest('.en__field');
if (fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert'))
return;
const errorAlert = document.createElement('div');
errorAlert.setAttribute('aria-live', 'polite');
Expand All @@ -29,10 +40,13 @@ export class A11y {
fieldElement.insertAdjacentElement('afterend', errorAlert);
});
}
addGroupRole() {
static addGroupRole(root = document) {
// Add role="group" to all EN Radio fields
const radioFields = document.querySelectorAll(".en__field--radio");
const radioFields = root.querySelectorAll(".en__field--radio");
radioFields.forEach((field) => {
// Skip fields already tagged so re-scans don't regenerate label IDs.
if (field.getAttribute("role") === "group")
return;
field.setAttribute("role", "group");
// Add random ID to the label
const label = field.querySelector("label");
Expand All @@ -42,19 +56,19 @@ export class A11y {
}
});
}
addRequired() {
const mandatoryFields = document.querySelectorAll(".en__mandatory .en__field__input");
static addRequired(root = document) {
const mandatoryFields = root.querySelectorAll(".en__mandatory .en__field__input");
mandatoryFields.forEach((field) => {
field.setAttribute("aria-required", "true");
});
}
addLabel() {
const otherAmount = document.querySelector(".en__field__input--otheramount");
static addLabel(root = document) {
const otherAmount = root.querySelector(".en__field__input--otheramount");
if (otherAmount) {
otherAmount.setAttribute("aria-label", "Enter your custom donation amount");
}
// Split selects usually don't have a label, so let's make the first option the label
const splitSelects = document.querySelectorAll(".en__field__input--splitselect");
const splitSelects = root.querySelectorAll(".en__field__input--splitselect");
splitSelects.forEach((select) => {
var _a, _b, _c, _d;
const firstOption = select.querySelector("option");
Expand Down Expand Up @@ -204,6 +218,47 @@ export class A11y {
inputElement.removeAttribute('aria-describedby');
}
}
/**
* Make everything on the page inert except the supplied overlay element and
* its ancestors. This hides background content from assistive technology and
* prevents focus from escaping a modal-style overlay.
*
* @param inert When true, set `inert` on all siblings of the overlay and of
* each of its ancestors. When false, remove `inert` from every
* element this method previously marked (tracked via the
* `data-engrid-inert` flag).
* @param overlay The element that should remain interactive. Required when
* `inert` is true; ignored when `inert` is false.
*/
static inertPage(inert, overlay) {
if (inert) {
if (!overlay)
return;
let element = overlay;
while (element && element !== document.body) {
const parent = element.parentElement;
if (parent) {
Array.from(parent.children).forEach((sibling) => {
if (sibling !== element &&
sibling instanceof HTMLElement &&
!sibling.hasAttribute("inert")) {
sibling.setAttribute("inert", "");
sibling.dataset.engridInert = "true";
}
});
}
element = parent;
}
}
else {
document
.querySelectorAll("[data-engrid-inert]")
.forEach((element) => {
element.removeAttribute("inert");
delete element.dataset.engridInert;
});
}
}
manageErrorListAlertRole() {
const errorList = document.querySelector('ul.en__errorList');
if (!errorList)
Expand Down
4 changes: 4 additions & 0 deletions packages/scripts/dist/ecard.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ export declare class Ecard {
constructor();
private shouldRun;
private checkRecipientFields;
private altsAndArias;
private altsAndAriasEcardItemsList;
private coupleLabelAndInput;
private coupleH2AndInput;
}
197 changes: 196 additions & 1 deletion packages/scripts/dist/ecard.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ENGrid, EnForm, EngridLogger } from ".";
import { ENGrid, EnForm, EngridLogger, A11y } from ".";
export class Ecard {
constructor() {
this._form = EnForm.getInstance();
this.logger = new EngridLogger("Ecard", "red", "#f5f5f5", "🪪");
if (!this.shouldRun())
return;
this.altsAndArias();
this._form.onValidate.subscribe(() => this.checkRecipientFields());
const schedule = ENGrid.getUrlParameter("engrid_ecard.schedule");
const scheduleField = ENGrid.getField("ecard.schedule");
Expand Down Expand Up @@ -58,4 +59,198 @@ export class Ecard {
}
return true;
}
altsAndArias() {
document.querySelectorAll(".en__ecarditems__list").forEach((list) => {
this.altsAndAriasEcardItemsList(list);
});
const ecardMessage = document.querySelector(".en__ecardmessage");
if (ecardMessage) {
this.coupleH2AndInput(ecardMessage, "Add a Message to your eCard");
}
const ecardRecipients = document.querySelector(".en__ecardrecipients");
if (ecardRecipients) {
const recipientName = ecardRecipients.querySelector(".en__ecardrecipients__name");
if (recipientName) {
this.coupleLabelAndInput(recipientName, "Recipient Name");
}
const recipientEmail = ecardRecipients.querySelector(".en__ecardrecipients__email");
if (recipientEmail) {
this.coupleLabelAndInput(recipientEmail, "Recipient Email");
}
}
const ecardFutureDelivery = document.querySelector(".en__ecardrecipients__futureDelivery");
if (ecardFutureDelivery) {
this.coupleH2AndInput(ecardFutureDelivery, "Schedule your eCard for future delivery");
}
const previewButton = document.querySelector(".en__ecarditems__showprev");
if (previewButton) {
previewButton.setAttribute("aria-controls", "ecard-preview");
previewButton.setAttribute("aria-haspopup", "dialog");
}
const previewModal = document.querySelector(".en__ecarditems__preview");
if (previewModal) {
previewModal.setAttribute("role", "dialog");
previewModal.setAttribute("aria-modal", "true");
previewModal.setAttribute("aria-label", "Ecard Preview Modal");
previewModal.setAttribute("id", "ecard-preview");
const closeButton = previewModal.querySelector(".en__ecarditems__prevclose");
if (closeButton) {
closeButton.setAttribute("role", "button");
closeButton.setAttribute("aria-label", "Close Preview");
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" &&
previewModal.classList.contains("preview--show")) {
closeButton.click();
}
});
}
const iframe = previewModal.querySelector("iframe");
if (iframe) {
iframe.setAttribute("title", "Ecard Preview Frame");
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" &&
mutation.attributeName === "class") {
const target = mutation.target;
if (target.classList.contains("preview--show")) {
A11y.inertPage(true, previewModal);
// Focus the iframe or the first focusable element in the modal
const focusableElements = previewModal.querySelectorAll('iframe, a[href], area[href], button:not([disabled]), object, embed, [tabindex="0"]');
if (focusableElements.length) {
focusableElements[0].focus();
}
}
else {
A11y.inertPage(false);
// Return focus to the preview button
if (previewButton) {
previewButton.focus();
}
}
}
});
});
observer.observe(previewModal, {
attributes: true,
attributeFilter: ["class"],
});
}
}
altsAndAriasEcardItemsList(list) {
// if there's a sibling h2, use its text as the aria-label for the list
const h2 = list.previousElementSibling;
if (h2 && h2.tagName === "H2") {
const id = `ecard-list-${Math.random().toString(36).substring(2, 9)}`;
h2.setAttribute("id", id);
list.setAttribute("aria-labelledby", id);
}
list.setAttribute("role", "radiogroup");
const thumbs = Array.from(list.querySelectorAll(".en__ecarditems__thumb"));
let isSelection = false;
thumbs.forEach((thumb, index) => {
thumb.setAttribute("role", "radio");
if (thumb.classList.contains("thumb--active")) {
thumb.setAttribute("aria-checked", "true");
thumb.setAttribute("tabindex", "0");
isSelection = true;
}
else {
thumb.setAttribute("aria-checked", "false");
thumb.setAttribute("tabindex", "-1");
}
const img = thumb.querySelector("img");
if (img) {
thumb.setAttribute("aria-label", img.alt || "Ecard Thumbnail");
img.setAttribute("aria-hidden", "true");
}
// Keyboard navigation (WAI-ARIA radio group pattern)
thumb.addEventListener("keydown", (e) => {
let nextIndex = null;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
nextIndex = (index + 1) % thumbs.length;
break;
case "ArrowLeft":
case "ArrowUp":
nextIndex = (index - 1 + thumbs.length) % thumbs.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = thumbs.length - 1;
break;
case "Enter":
case " ":
e.preventDefault();
thumb.click();
return;
default:
return;
}
e.preventDefault();
// In a radio group, moving focus also selects the option.
// click() lets EN's own handler set the value + thumb--active class;
// the MutationObserver below then syncs aria-checked + tabindex.
thumbs[nextIndex].focus();
thumbs[nextIndex].click();
});
});
if (!isSelection && thumbs.length) {
thumbs[0].setAttribute("tabindex", "0");
}
// MutationObserver to watch for "thumb--active" class changes and keep
// aria-checked + roving tabindex in sync with the selected thumb
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" &&
mutation.attributeName === "class") {
const target = mutation.target;
if (target.classList.contains("thumb--active")) {
target.setAttribute("aria-checked", "true");
// Roving tabindex: only the active thumb is tabbable
target.setAttribute("tabindex", "0");
thumbs.forEach((t) => {
if (t !== target)
t.setAttribute("tabindex", "-1");
});
}
else {
target.setAttribute("aria-checked", "false");
}
}
});
});
observer.observe(list, {
attributes: true,
subtree: true,
attributeFilter: ["class"],
});
}
coupleLabelAndInput(parent, labelText) {
const label = parent.querySelector("label");
const input = parent.querySelector("input, textarea, select");
if (label && input) {
const id = `ecard-input-${Math.random().toString(36).substring(2, 9)}`;
label.setAttribute("id", id);
input.setAttribute("aria-labelledby", id);
}
else if (input) {
input.setAttribute("aria-label", labelText);
}
}
coupleH2AndInput(parent, labelText) {
const h2 = parent.querySelector("h2");
const input = parent.querySelector("textarea, input, select");
if (h2 && input) {
const id = `ecard-message-${Math.random().toString(36).substring(2, 9)}`;
h2.setAttribute("id", id);
input.setAttribute("aria-labelledby", id);
}
else if (input) {
input.setAttribute("aria-label", labelText);
}
}
}
Loading
Loading