|
| 1 | +import { EngridLogger } from "./logger"; |
1 | 2 | // a11y means accessibility |
2 | 3 | // This Component is supposed to be used as a helper for Aria Attributes & Other Accessibility Features |
3 | 4 |
|
4 | 5 | export class A11y { |
| 6 | + private logger: EngridLogger = new EngridLogger( |
| 7 | + "A11y", |
| 8 | + "#FFFFFF", |
| 9 | + "#811212", |
| 10 | + "👁️🗨️" |
| 11 | + ); |
| 12 | + |
| 13 | + private observer: MutationObserver | null = null; |
| 14 | + |
5 | 15 | constructor() { |
| 16 | + this.addErrorAlertArea(); |
6 | 17 | this.addRequired(); |
7 | 18 | this.addLabel(); |
8 | 19 | this.addGroupRole(); |
9 | 20 | this.updateFrequencyLabel(); |
10 | 21 | const ecardImages: NodeListOf<HTMLImageElement> = document.querySelectorAll('.en__ecarditems__list img'); |
11 | 22 | this.setAutoGeneratedAltTags(ecardImages); |
12 | 23 | this.manageErrorListAlertRole(); |
| 24 | + this.observeErrorMessages(); |
| 25 | + } |
| 26 | + |
| 27 | + private addErrorAlertArea() { |
| 28 | + const fieldElements = document.querySelectorAll('.en__field .en__field__element') |
| 29 | + fieldElements.forEach((fieldElement) => { |
| 30 | + if (fieldElement.nextElementSibling?.classList.contains('en__field__error__alert')) return; |
| 31 | + const errorAlert = document.createElement('div'); |
| 32 | + errorAlert.setAttribute('aria-live', 'polite'); |
| 33 | + errorAlert.setAttribute('aria-atomic', 'true'); |
| 34 | + errorAlert.classList.add('en__field__error__alert'); |
| 35 | + errorAlert.id = `en__field__error__alert--${Math.random().toString(36).slice(2, 7)}`; |
| 36 | + fieldElement.insertAdjacentElement('afterend', errorAlert); |
| 37 | + }) |
13 | 38 | } |
14 | 39 |
|
15 | 40 | private addGroupRole() { |
@@ -91,32 +116,126 @@ export class A11y { |
91 | 116 | images.forEach((img: HTMLImageElement) => { |
92 | 117 | // Skip if the alt tag is already set |
93 | 118 | if (img.alt) return; |
94 | | - |
| 119 | + |
95 | 120 | try { |
96 | 121 | // Extract the filename from the `src` attribute |
97 | 122 | const src: string | null = img.src; |
98 | 123 | if (!src) throw new Error("Image src is null or undefined"); |
99 | | - |
| 124 | + |
100 | 125 | const url = new URL(src); |
101 | 126 | const fileNameWithExtension: string | undefined = url.pathname.split('/').pop(); |
102 | 127 | if (!fileNameWithExtension) throw new Error("No filename found in src"); |
103 | | - |
| 128 | + |
104 | 129 | // Remove the file extension and replace `-` and `_` with spaces |
105 | 130 | let altText: string = fileNameWithExtension.split('.').shift()?.replace(/[-_]/g, ' ') || ''; |
106 | | - |
| 131 | + |
107 | 132 | // Remove dimensions (#x#) and anything that follows |
108 | 133 | altText = altText.replace(/\d+x\d+.*$/, '').trim(); |
109 | | - |
| 134 | + |
110 | 135 | // Wrap in the disclaimer |
111 | 136 | altText = `This is an auto-generated alt tag from the filename: ${altText}`; |
112 | | - |
| 137 | + |
113 | 138 | // Set the generated alt text on the image |
114 | 139 | img.alt = altText; |
115 | 140 | } catch (error) { |
116 | | - console.error(`Error processing image: ${img.src}`, error); |
| 141 | + this.logger.danger(`Error processing image: ${img.src}`, error); |
| 142 | + } |
| 143 | + }); |
| 144 | + } |
| 145 | + |
| 146 | + /** |
| 147 | + * Observe #engrid for .en__field__error additions and removals, mirroring |
| 148 | + * text into the per-field .en__field__error__alert live region and toggling |
| 149 | + * aria-invalid / aria-describedby on the corresponding input. Runs for the |
| 150 | + * lifetime of the page so async validators (NeverBounce, VGS, server |
| 151 | + * re-renders) are caught without timing assumptions. |
| 152 | + */ |
| 153 | + private observeErrorMessages(): void { |
| 154 | + const root = document.getElementById('engrid') ?? document.body; |
| 155 | + |
| 156 | + this.observer = new MutationObserver(records => { |
| 157 | + for (const record of records) { |
| 158 | + if (record.type !== 'childList') continue; |
| 159 | + |
| 160 | + record.addedNodes.forEach(node => { |
| 161 | + if (node instanceof HTMLElement && node.classList.contains('en__field__error')) { |
| 162 | + this.moveErrorMessage(node); |
| 163 | + } |
| 164 | + }); |
| 165 | + |
| 166 | + record.removedNodes.forEach(node => { |
| 167 | + if (!(node instanceof HTMLElement)) return; |
| 168 | + if (!node.classList.contains('en__field__error')) return; |
| 169 | + // node.parentElement is null after removal; record.target is the |
| 170 | + // former parent. Walk up to the enclosing .en__field to be defensive |
| 171 | + // against deeper nesting. |
| 172 | + const fieldWrapper = (record.target as HTMLElement).closest?.('.en__field'); |
| 173 | + const alert = fieldWrapper?.querySelector<HTMLElement>('.en__field__error__alert'); |
| 174 | + if (alert) this.clearErrorMessage(alert); |
| 175 | + }); |
117 | 176 | } |
118 | 177 | }); |
| 178 | + |
| 179 | + this.observer.observe(root, { childList: true, subtree: true }); |
| 180 | + |
| 181 | + // Initial sweep for errors rendered server-side or by scripts that ran |
| 182 | + // before this observer was attached. |
| 183 | + document.querySelectorAll<HTMLElement>('.en__field').forEach(field => { |
| 184 | + const error = field.querySelector<HTMLElement>('.en__field__error'); |
| 185 | + const alert = field.querySelector<HTMLElement>('.en__field__error__alert'); |
| 186 | + if (error) this.moveErrorMessage(error); |
| 187 | + else if (alert) this.clearErrorMessage(alert); |
| 188 | + }); |
| 189 | + } |
| 190 | + |
| 191 | + private moveErrorMessage(field: HTMLElement): void { |
| 192 | + if (field.closest('.en__field__error__alert')) return; |
| 193 | + const fieldWrapper = field.closest('.en__field'); |
| 194 | + if (!fieldWrapper) return; |
| 195 | + |
| 196 | + const alertContainer = fieldWrapper.querySelector<HTMLElement>('.en__field__error__alert'); |
| 197 | + if (!alertContainer) return; |
| 198 | + |
| 199 | + alertContainer.textContent = field.textContent; |
| 200 | + field.classList.add('en__field__error--hidden-by-a11y'); |
| 201 | + |
| 202 | + const inputElement = fieldWrapper.querySelector<HTMLElement>( |
| 203 | + '.en__field__element input, .en__field__element select, .en__field__element textarea' |
| 204 | + ); |
| 205 | + if (!inputElement) return; |
| 206 | + |
| 207 | + inputElement.setAttribute('aria-invalid', 'true'); |
| 208 | + const describedBy = (inputElement.getAttribute('aria-describedby') ?? '') |
| 209 | + .split(/\s+/) |
| 210 | + .filter(Boolean); |
| 211 | + if (describedBy.indexOf(alertContainer.id) === -1) { |
| 212 | + describedBy.push(alertContainer.id); |
| 213 | + } |
| 214 | + inputElement.setAttribute('aria-describedby', describedBy.join(' ')); |
| 215 | + } |
| 216 | + |
| 217 | + private clearErrorMessage(alert: HTMLElement): void { |
| 218 | + alert.textContent = ''; |
| 219 | + |
| 220 | + const fieldWrapper = alert.closest('.en__field'); |
| 221 | + if (!fieldWrapper) return; |
| 222 | + |
| 223 | + const inputElement = fieldWrapper.querySelector<HTMLElement>( |
| 224 | + '.en__field__element input, .en__field__element select, .en__field__element textarea' |
| 225 | + ); |
| 226 | + if (!inputElement) return; |
| 227 | + |
| 228 | + inputElement.removeAttribute('aria-invalid'); |
| 229 | + const remaining = (inputElement.getAttribute('aria-describedby') ?? '') |
| 230 | + .split(/\s+/) |
| 231 | + .filter(id => id && id !== alert.id); |
| 232 | + if (remaining.length) { |
| 233 | + inputElement.setAttribute('aria-describedby', remaining.join(' ')); |
| 234 | + } else { |
| 235 | + inputElement.removeAttribute('aria-describedby'); |
| 236 | + } |
119 | 237 | } |
| 238 | + |
120 | 239 | private manageErrorListAlertRole(): void { |
121 | 240 | const errorList = document.querySelector<HTMLUListElement>('ul.en__errorList'); |
122 | 241 | if (!errorList) return; |
|
0 commit comments