@govtechsg/oobee 0.10.20 → 0.10.28

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.
Files changed (42) hide show
  1. package/.github/workflows/docker-test.yml +1 -1
  2. package/DETAILS.md +40 -25
  3. package/Dockerfile +41 -47
  4. package/LICENSE-3RD-PARTY-REPORT.txt +448 -0
  5. package/LICENSE-3RD-PARTY.txt +19913 -0
  6. package/README.md +26 -0
  7. package/__mocks__/mock-report.html +1503 -1360
  8. package/package.json +9 -5
  9. package/scripts/decodeUnzipParse.js +29 -0
  10. package/scripts/install_oobee_dependencies.command +2 -2
  11. package/scripts/install_oobee_dependencies.ps1 +3 -3
  12. package/src/cli.ts +9 -7
  13. package/src/combine.ts +13 -5
  14. package/src/constants/cliFunctions.ts +38 -1
  15. package/src/constants/common.ts +31 -5
  16. package/src/constants/constants.ts +28 -26
  17. package/src/constants/questions.ts +4 -1
  18. package/src/crawlers/commonCrawlerFunc.ts +114 -152
  19. package/src/crawlers/crawlDomain.ts +25 -32
  20. package/src/crawlers/crawlIntelligentSitemap.ts +7 -1
  21. package/src/crawlers/crawlLocalFile.ts +1 -1
  22. package/src/crawlers/crawlSitemap.ts +1 -1
  23. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +546 -472
  24. package/src/crawlers/customAxeFunctions.ts +1 -1
  25. package/src/index.ts +2 -2
  26. package/src/mergeAxeResults.ts +590 -214
  27. package/src/screenshotFunc/pdfScreenshotFunc.ts +3 -3
  28. package/src/static/ejs/partials/components/scanAbout.ejs +65 -0
  29. package/src/static/ejs/partials/components/wcagCompliance.ejs +10 -29
  30. package/src/static/ejs/partials/footer.ejs +10 -13
  31. package/src/static/ejs/partials/scripts/categorySummary.ejs +2 -2
  32. package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +3 -0
  33. package/src/static/ejs/partials/scripts/reportSearch.ejs +1 -0
  34. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +54 -52
  35. package/src/static/ejs/partials/scripts/scanAboutScript.ejs +38 -0
  36. package/src/static/ejs/partials/styles/styles.ejs +26 -1
  37. package/src/static/ejs/partials/summaryMain.ejs +15 -42
  38. package/src/static/ejs/report.ejs +22 -12
  39. package/src/utils.ts +10 -2
  40. package/src/xPathToCss.ts +186 -0
  41. package/a11y-scan-results.zip +0 -0
  42. package/src/types/xpath-to-css.d.ts +0 -3
@@ -98,6 +98,50 @@ export const flagUnlabelledClickableElements = async (page: Page) => {
98
98
  return false;
99
99
  }
100
100
 
101
+ function isInOpenDetails(element:Element) {
102
+ let parentDetails = element.closest('details');
103
+ return parentDetails ? parentDetails.open : true;
104
+ }
105
+
106
+ function isVisibleFocusAble(el:Element) {
107
+ if (!el)
108
+ {
109
+ return false;
110
+ }
111
+ if (el.nodeName !== undefined && el.nodeName === "#text") // cause #text cannot getComputedStyle
112
+ {
113
+ return false;
114
+ }
115
+ try {
116
+ const style = window.getComputedStyle(el);
117
+ const rect = el.getBoundingClientRect();
118
+ return (
119
+ // Visible
120
+ style.display !== 'none'
121
+ && style.visibility !== 'hidden'
122
+ && style.opacity !== "0"
123
+ && rect.width > 0 && rect.height > 0
124
+ // <detail> tag will show it as visual so need to account for that
125
+ && isInOpenDetails(el)
126
+ );
127
+ } catch (error) {
128
+ console.log("Error in ELEMENT",el,error.message)
129
+ return false;
130
+ }
131
+ };
132
+
133
+ function isValidUnicode(text :string) {
134
+ if (typeof text !== "string") {
135
+ return false;
136
+ }
137
+
138
+ // Regular expression to match valid Unicode characters, including surrogate pairs
139
+ const validTextOrEmojiRegex = /[\p{L}\p{N}\p{S}\p{P}\p{Emoji}]/gu; // Letters, numbers, symbols, punctuation, and emojis
140
+
141
+ // Check if the text contains at least one valid character or emoji
142
+ return validTextOrEmojiRegex.test(text);
143
+ }
144
+
101
145
  function getElementById(element: Element, id: string) {
102
146
  return element.ownerDocument.getElementById(id);
103
147
  }
@@ -231,6 +275,76 @@ export const flagUnlabelledClickableElements = async (page: Page) => {
231
275
  return false;
232
276
  }
233
277
 
278
+ function hasChildNotANewInteractWithAccessibleText(element: Element) {
279
+ // Helper function to check if the element is a link or button
280
+ const isLinkOrButton = (child: Node) => {
281
+ if (child instanceof Element) { // Check if the child is an Element
282
+ return child.nodeName.toLowerCase() === "a" ||
283
+ child.nodeName.toLowerCase() === "button" ||
284
+ child.getAttribute('role') === 'link' ||
285
+ child.getAttribute('role') === 'button';
286
+ }
287
+ return false;
288
+ };
289
+
290
+ // Check element children
291
+ const hasAccessibleChildElement = Array.from(element.children).some(child => {
292
+ if (child instanceof Element) { // Ensure child is an Element
293
+ if (!hasPointerCursor(child)) {
294
+ return false;
295
+ }
296
+
297
+ if (child.nodeName.toLowerCase() === "style" || child.nodeName.toLowerCase() === "script") {
298
+ return false;
299
+ }
300
+
301
+ // Skip children that are aria-hidden or links/buttons
302
+ if (child.getAttribute('aria-hidden') === 'true' || isLinkOrButton(child) || !isVisibleFocusAble(child)) {
303
+ return false;
304
+ }
305
+
306
+ // Check if the child element has accessible text or label
307
+ return isAccessibleText(getTextContent(child)) || hasAccessibleLabel(child) || hasCSSContent(child);
308
+ }
309
+ return false;
310
+ });
311
+
312
+ // Check direct text nodes inside the element itself (like <a>"text"</a>)
313
+ const hasDirectAccessibleText = Array.from(element.childNodes).some(node => {
314
+ if (node.nodeType === Node.TEXT_NODE) {
315
+ const textContent = getTextContent(node);
316
+
317
+ // Check if the text contains non-ASCII characters (Unicode)
318
+ const containsUnicode = /[^\x00-\x7F]/.test(textContent);
319
+
320
+ // If contains non-ASCII characters, validate with isValidUnicode
321
+ if (containsUnicode) {
322
+ return isValidUnicode(textContent);
323
+ }
324
+
325
+ // Otherwise, just check if it's non-empty text
326
+ return textContent.length > 0;
327
+ }
328
+
329
+ // Recursively check for text content inside child nodes of elements that are not links or buttons
330
+ if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element && !isLinkOrButton(node)) {
331
+ return Array.from(node.childNodes).some(innerNode => {
332
+ if (innerNode.nodeType === Node.TEXT_NODE) {
333
+ const innerTextContent = getTextContent(innerNode).trim();
334
+ return innerTextContent && !isValidUnicode(innerTextContent); // Check for non-Unicode content
335
+ }
336
+ return false;
337
+ });
338
+ }
339
+
340
+ return false;
341
+ });
342
+
343
+
344
+ return hasAccessibleChildElement || hasDirectAccessibleText;
345
+ }
346
+
347
+
234
348
  const style = document.createElement('style');
235
349
  style.innerHTML = `
236
350
  .highlight-flagged {
@@ -290,513 +404,473 @@ export const flagUnlabelledClickableElements = async (page: Page) => {
290
404
  return rect.width < 1 || rect.height < 1;
291
405
  }
292
406
 
293
- function shouldFlagElement(element: HTMLElement, allowNonClickableFlagging: boolean) {
294
- // if (!element || !(element instanceof Element)) {
295
- // customConsoleWarn("Element is null or not a valid Element.");
296
- // return false;
297
- // }
298
-
299
- // if (element.nodeName.toLowerCase() === "a")
300
- // {
301
- // }
302
-
303
- if (isElementTooSmall(element)) {
304
- return false;
305
- }
306
-
307
- // Skip non-clickable elements if allowNonClickableFlagging is false
308
- if (allowNonClickableFlagging && !hasPointerCursor(element)) {
309
- customConsoleWarn(
310
- 'Element is not clickable and allowNonClickableFlagging is false, skipping flagging.',
311
- );
312
- return false;
407
+ function getTextContent(element: Element | ChildNode): string {
408
+ if (element.nodeType === Node.TEXT_NODE) {
409
+ return element.nodeValue?.trim() ?? ''; // Return the text directly if it's a TEXT_NODE
313
410
  }
314
-
315
- // Do not flag elements if any ancestor has aria-hidden="true"
316
- if (element.closest('[aria-hidden="true"]')) {
317
- customConsoleWarn("An ancestor element has aria-hidden='true', skipping flagging.");
318
- return false;
411
+
412
+ let textContent = '';
413
+
414
+ for (const node of element.childNodes) {
415
+ if (node.nodeType === Node.TEXT_NODE) {
416
+ textContent += node.nodeValue?.trim() ?? ''; // Append text content from text nodes
417
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
418
+ // Type assertion: node is an Element
419
+ const elementNode = node as Element;
420
+
421
+ // If it's an SVG and has a <title> tag inside it, we want to grab that text
422
+ if (elementNode.tagName.toLowerCase() === 'svg') {
423
+ const titleElement = elementNode.querySelector('title');
424
+ if (titleElement && isVisibleFocusAble(elementNode)) {
425
+ return titleElement.textContent?.trim() ?? ''; // Return the title text if valid
426
+ }
427
+ }
428
+
429
+ // Recursively check child elements if it's an element node
430
+ if (isVisibleFocusAble(elementNode)) {
431
+ const childText = getTextContent(elementNode);
432
+ if (childText) {
433
+ textContent += childText; // Append valid child text
434
+ }
435
+ }
436
+ }
319
437
  }
438
+
439
+ return textContent.trim(); // Return the combined text content
440
+ }
441
+
320
442
 
321
- let parents = element.parentElement;
322
-
323
- // Causing false negative of svg
324
- if (parents) {
325
- // Check if the parent has an accessible label
326
- if (hasAccessibleLabel(parents) || hasChildWithAccessibleText(parents)) {
327
- customConsoleWarn(
328
- 'Parent element has an accessible label, skipping flagging of this element.',
329
- );
330
- return false;
443
+ function shouldFlagElement(element: HTMLElement, allowNonClickableFlagging: boolean) {
444
+ if (isElementTooSmall(element))
445
+ {
446
+ return false;
331
447
  }
332
-
333
- /* TODO: Ask if this condition is needed cause this is what is causing the hamburger to not */
334
- // Check if any sibling (that is not an interactable) has an accessible label
335
- // const siblings = Array.from(parents.children);
336
- // const hasAccessibleSibling = siblings.some(sibling =>
337
- // sibling !== element && (hasAccessibleLabel(sibling) || hasChildWithAccessibleText(sibling))
338
- // );
339
- // if (hasAccessibleSibling) {
340
- // customConsoleWarn("A sibling element has an accessible label, skipping flagging.");
341
- // return false;
342
- // }
343
- }
344
-
345
- while (parents) {
346
- // Skip flagging if the parent or the element itself has an accessible label
347
- if (hasAccessibleLabel(parents) || hasAccessibleLabel(element)) {
348
- customConsoleWarn('Parent or element has an accessible label, skipping flagging.');
349
- return false;
448
+
449
+ // Skip non-clickable elements if allowNonClickableFlagging is false
450
+ if (allowNonClickableFlagging && !hasPointerCursor(element)) {
451
+ customConsoleWarn("Element is not clickable and allowNonClickableFlagging is false, skipping flagging.");
452
+ return false;
350
453
  }
351
-
352
- // Skip flagging if the parent is a button-like element with aria-expanded
353
- if (
354
- parents.getAttribute('role') === 'button' &&
355
- (parents.hasAttribute('aria-expanded') || parents.hasAttribute('aria-controls'))
356
- ) {
357
- customConsoleWarn(
358
- 'Parent element is an interactive button with aria-expanded or aria-controls, skipping flagging.',
359
- );
360
- return false;
454
+
455
+ // Do not flag elements if any ancestor has aria-hidden="true"
456
+ if (element.closest('[aria-hidden="true"]')) {
457
+ customConsoleWarn("An ancestor element has aria-hidden='true', skipping flagging.");
458
+ return false;
361
459
  }
362
-
363
- // Skip flagging if an ancestor has an accessible label or an interactive role (e.g., button, link)
364
- if (
365
- ['div', 'section', 'article', 'nav'].includes(parents.nodeName.toLowerCase()) &&
366
- hasAccessibleLabel(parents)
367
- ) {
368
- customConsoleWarn(
369
- 'Ancestor element with contextual role has an accessible label, skipping flagging.',
370
- );
371
- return false;
460
+
461
+ let parents = element.parentElement;
462
+
463
+ // Causing false negative of svg
464
+ if (parents) {
465
+ // Check if the parent has an accessible label
466
+ if (hasAccessibleLabel(parents) || hasChildNotANewInteractWithAccessibleText(parents)) {
467
+ customConsoleWarn("Parent element has an accessible label, skipping flagging of this element.");
468
+ return false;
469
+ }
470
+
471
+ }
472
+
473
+ let maxLayers = 3;
474
+ let tracedBackedLayerCount = 0;
475
+ while (parents && tracedBackedLayerCount <= maxLayers) {
476
+ // Skip flagging if the parent or the element itself has an accessible label
477
+ if (hasAccessibleLabel(parents) || hasChildNotANewInteractWithAccessibleText(parents)) {
478
+ customConsoleWarn("Parent or element has an accessible label, skipping flagging.",parents);
479
+ return false;
480
+ }
481
+
482
+ // Skip flagging if the parent is a button-like element with aria-expanded
483
+ if (
484
+ parents.getAttribute('role') === 'button' &&
485
+ (parents.hasAttribute('aria-expanded') || parents.hasAttribute('aria-controls'))
486
+ ) {
487
+ customConsoleWarn("Parent element is an interactive button with aria-expanded or aria-controls, skipping flagging.");
488
+ return false;
489
+ }
490
+
491
+ // Skip flagging if an ancestor has an accessible label or an interactive role (e.g., button, link)
492
+ if (
493
+ ['div', 'section', 'article', 'nav'].includes(parents.nodeName.toLowerCase()) &&
494
+ hasAccessibleLabel(parents)
495
+ ) {
496
+ customConsoleWarn("Ancestor element with contextual role has an accessible label, skipping flagging.");
497
+ return false;
498
+ }
499
+
500
+ // Skip flag if parent is an a link or button that already contains accessible text
501
+ if (
502
+ (parents.nodeName.toLowerCase() === "a" || parents.nodeName.toLowerCase() === "button" ||
503
+ parents.getAttribute('role') === 'link' || parents.getAttribute('role') === 'button') && hasChildWithAccessibleText(parents)
504
+ ){
505
+ return false;
506
+ }
507
+
508
+ parents = parents.parentElement;
509
+ tracedBackedLayerCount++;
372
510
  }
373
-
374
- parents = parents.parentElement;
375
- }
376
-
377
- // Skip elements with role="menuitem" if an accessible sibling, parent, or child is present
378
- if (element.getAttribute('role') === 'menuitem') {
379
- if (
380
- hasSiblingWithAccessibleLabel(element) ||
381
- hasChildWithAccessibleText(element) ||
382
- hasAccessibleLabel(element.parentElement)
383
- ) {
384
- customConsoleWarn(
385
- 'Menuitem element or its sibling/parent has an accessible label, skipping flagging.',
386
- );
387
- return false;
511
+
512
+
513
+ // Skip elements with role="menuitem" if an accessible sibling, parent, or child is present
514
+ if (element.getAttribute('role') === 'menuitem') {
515
+ if (hasSiblingWithAccessibleLabel(element) || hasChildWithAccessibleText(element) || hasAccessibleLabel(element.parentElement)) {
516
+ customConsoleWarn("Menuitem element or its sibling/parent has an accessible label, skipping flagging.");
517
+ return false;
518
+ }
388
519
  }
389
- }
390
-
391
- // Skip flagging child elements if the parent element has role="menuitem" and is accessible
392
- const parentMenuItem = element.closest('[role="menuitem"]');
393
- if (
394
- parentMenuItem &&
395
- (hasAccessibleLabel(parentMenuItem) || hasChildWithAccessibleText(parentMenuItem))
396
- ) {
397
- customConsoleWarn(
398
- 'Parent menuitem element has an accessible label or child with accessible text, skipping flagging of its children.',
399
- );
400
- return false;
401
- }
402
-
403
- // Add the new condition for empty div or span elements without any accessible text or children with accessible labels
404
- if (
405
- (element.nodeName.toLowerCase() === 'span' || element.nodeName.toLowerCase() === 'div') &&
406
- element.children.length === 0 &&
407
- element.textContent.trim().length === 0
408
- ) {
409
- const parent = element.parentElement;
410
- if (parent) {
411
- const hasAccessibleChild = Array.from(parent.children).some(
412
- child => child !== element && hasAccessibleLabel(child),
413
- );
414
-
415
- if (hasAccessibleChild) {
416
- customConsoleWarn(
417
- 'Parent element has an accessible child, skipping flagging of empty span or div.',
418
- );
520
+
521
+ // Skip flagging child elements if the parent element has role="menuitem" and is accessible
522
+ const parentMenuItem = element.closest('[role="menuitem"]');
523
+ if (parentMenuItem && (hasAccessibleLabel(parentMenuItem) || hasChildWithAccessibleText(parentMenuItem))) {
524
+ customConsoleWarn("Parent menuitem element has an accessible label or child with accessible text, skipping flagging of its children.");
419
525
  return false;
420
- }
421
526
  }
422
- }
423
-
424
- // Do not flag elements with aria-hidden="true"
425
- if (element.getAttribute('aria-hidden') === 'true') {
426
- customConsoleWarn('Element is aria-hidden, skipping flagging.');
427
- return false;
428
- }
429
-
430
- // Do not flag elements with role="presentation"
431
- if (element.getAttribute('role') === 'presentation') {
432
- customConsoleWarn("Element has role='presentation', skipping flagging.");
433
- return false;
434
- }
435
-
436
- if (element.dataset.flagged === 'true') {
437
- customConsoleWarn('Element is already flagged.');
438
- return false;
439
- }
440
-
441
- // If an ancestor element is flagged, do not flag this element
442
- if (element.closest('[data-flagged="true"]')) {
443
- customConsoleWarn('An ancestor element is already flagged.');
444
- return false;
445
- }
446
-
447
- // Skip elements that are not visible (e.g., display:none)
448
- const computedStyle = element.ownerDocument.defaultView.getComputedStyle(element);
449
- if (
450
- computedStyle.display === 'none' ||
451
- computedStyle.visibility === 'hidden' ||
452
- element.offsetParent === null
453
- ) {
454
- customConsoleWarn('Element is not visible, skipping flagging.');
455
- return false;
456
- }
457
-
458
- // Skip empty <div> or <span> elements without any accessible text or children with accessible labels, unless they have a pointer cursor
459
- if (
460
- (element.nodeName.toLowerCase() === 'div' || element.nodeName.toLowerCase() === 'span') &&
461
- element.children.length === 0 &&
462
- element.textContent.trim().length === 0
463
- ) {
464
- if (!hasPointerCursor(element)) {
465
- customConsoleWarn(
466
- 'Empty div or span without accessible text and without pointer cursor, skipping flagging.',
467
- );
468
- return false;
527
+
528
+ // Add the new condition for empty div or span elements without any accessible text or children with accessible labels
529
+ if ((element.nodeName.toLowerCase() === 'span' || element.nodeName.toLowerCase() === 'div') &&
530
+ element.children.length === 0 && getTextContent(element).trim().length === 0) {
531
+ const parent = element.parentElement;
532
+ if (parent) {
533
+ const hasAccessibleChild = Array.from(parent.children).some(child =>
534
+ child !== element && hasAccessibleLabel(child)
535
+ );
536
+
537
+ if (hasAccessibleChild) {
538
+ customConsoleWarn("Parent element has an accessible child, skipping flagging of empty span or div.");
539
+ return false;
540
+ }
541
+ }
469
542
  }
470
-
471
- // **New background-image check**
472
- const backgroundImage = window
473
- .getComputedStyle(element)
474
- .getPropertyValue('background-image');
475
- if (backgroundImage && backgroundImage !== 'none') {
476
- customConsoleWarn('Element has a background image.');
477
-
478
- // Check if the element has accessible labels or text content
479
- if (
480
- !hasAccessibleLabel(element) &&
481
- !hasChildWithAccessibleText(element) &&
482
- !isAccessibleText(element.textContent)
483
- ) {
484
- customConsoleWarn(
485
- 'Flagging element with background image but without accessible label or text.',
486
- );
487
- return true; // Flag the element
488
- }
489
- customConsoleWarn(
490
- 'Element with background image has accessible label or text, skipping flagging.',
491
- );
492
- return false; // Do not flag
493
- }
494
-
495
- // **Proceed with ancestor traversal if no background image is found**
496
- // Traverse ancestors to check for interactive elements with accessible labels
497
- let ancestor = element.parentElement;
498
- let depth = 0;
499
- const maxDepth = 4; // Limit the depth to prevent skipping elements incorrectly
500
- while (ancestor && depth < maxDepth) {
501
- // Determine if ancestor is interactive
502
- const isAncestorInteractive =
503
- hasPointerCursor(ancestor) ||
504
- ancestor.hasAttribute('onclick') ||
505
- ancestor.hasAttribute('role') ||
506
- (ancestor.hasAttribute('tabindex') && ancestor.getAttribute('tabindex') !== '-1') ||
507
- ancestor.hasAttribute('jsaction') ||
508
- ancestor.hasAttribute('jscontroller');
509
-
510
- if (isAncestorInteractive) {
511
- // Check if ancestor has accessible label or text content
512
- if (
513
- hasAccessibleLabel(ancestor) ||
514
- isAccessibleText(ancestor.textContent) ||
515
- hasChildWithAccessibleText(ancestor)
516
- ) {
517
- customConsoleWarn(
518
- 'Ancestor interactive element has accessible label or text content, skipping flagging.',
519
- );
520
- return false;
543
+
544
+ // Do not flag elements with aria-hidden="true"
545
+ if (element.getAttribute('aria-hidden') === 'true') {
546
+ customConsoleWarn("Element is aria-hidden, skipping flagging.");
547
+ return false;
548
+ }
549
+
550
+ if (element.getAttribute("aria-labelledby") !== null && element.getAttribute("aria-labelledby") !== "") {
551
+ // Get the list of IDs referenced in aria-labelledby
552
+ const ids = element.getAttribute("aria-labelledby").split(' ');
553
+ let shouldNotFlag = false
554
+
555
+ // Loop through each ID and find the corresponding elements
556
+ ids.forEach(id => {
557
+ const referencedElement = document.getElementById(id);
558
+
559
+ // Check if the element was found
560
+ if (referencedElement &&
561
+ (hasAccessibleLabel(referencedElement) ||
562
+ isAccessibleText(getTextContent(referencedElement)) ||
563
+ hasAllChildrenAccessible(referencedElement) ))
564
+ {
565
+ shouldNotFlag = true;
566
+ }
567
+ });
568
+
569
+ if (shouldNotFlag)
570
+ {
571
+ return false
521
572
  }
522
- // Ancestor is interactive but lacks accessible labeling
523
- customConsoleWarn(
524
- 'Ancestor interactive element lacks accessible label, continue flagging.',
525
- );
526
- // Do not skip flagging
527
- }
528
- ancestor = ancestor.parentElement;
529
- depth++;
573
+
530
574
  }
531
-
532
- // If no interactive ancestor with accessible label is found, flag the element
533
- customConsoleWarn(
534
- 'Flagging clickable div or span with pointer cursor and no accessible text.',
535
- );
536
- return true;
537
- }
538
-
539
- // Skip elements with role="menuitem" and ensure accessibility label for any nested elements
540
- if (element.getAttribute('role') === 'menuitem') {
541
- if (hasChildWithAccessibleText(element)) {
542
- customConsoleWarn('Menuitem element has child with accessible text, skipping flagging.');
543
- return false;
575
+
576
+ // Do not flag elements with role="presentation"
577
+ if (element.getAttribute('role') === 'presentation') {
578
+ customConsoleWarn("Element has role='presentation', skipping flagging.");
579
+ return false;
544
580
  }
545
- }
546
-
547
- // Check if the parent element has an accessible label
548
- const parent = element.closest('[aria-label], [role="button"], [role="link"], a, button');
549
-
550
- if (parent && (hasAccessibleLabel(parent) || hasChildWithAccessibleText(parent))) {
551
- customConsoleWarn(
552
- 'Parent element has an accessible label or accessible child, skipping flagging.',
553
- );
554
- return false;
555
- }
556
-
557
- // Skip flagging if any child has an accessible label (e.g., <img alt="...">
558
- if (hasAllChildrenAccessible(element)) {
559
- customConsoleWarn('Element has child nodes with accessible text.');
560
- return false;
561
- }
562
-
563
- // Check if the <a> element has all children accessible
564
- if (element.nodeName.toLowerCase() === 'a' && hasAllChildrenAccessible(element)) {
565
- customConsoleWarn('Hyperlink has all children with accessible labels, skipping flagging.');
566
- return false;
567
- }
568
-
569
- if (element.hasAttribute('tabindex') && element.getAttribute('tabindex') === '-1') {
570
- customConsoleWarn("Element has tabindex='-1'.");
571
- return false;
572
- }
573
-
574
- const childWithTabindexNegativeOne = Array.from(element.children).some(
575
- child => child.hasAttribute('tabindex') && child.getAttribute('tabindex') === '-1',
576
- );
577
- if (childWithTabindexNegativeOne) {
578
- customConsoleWarn("Element has a child with tabindex='-1'.");
579
- return false;
580
- }
581
-
582
- if (landmarkElements.includes(element.nodeName.toLowerCase())) {
583
- customConsoleWarn('Element is a landmark element.');
584
- return false;
585
- }
586
-
587
- // Prevent flagging <svg> or <icon> if a sibling or parent has an accessible label or if it is part of a button-like element
588
- if (
589
- (element.nodeName.toLowerCase() === 'svg' || element.nodeName.toLowerCase() === 'icon') &&
590
- (element.getAttribute('focusable') === 'false' ||
591
- hasSiblingOrParentAccessibleLabel(element) ||
592
- element.closest('[role="button"]') ||
593
- element.closest('button'))
594
- ) {
595
- customConsoleWarn(
596
- 'Sibling or parent element has an accessible label or svg is part of a button, skipping flagging of svg or icon.',
597
- );
598
- return false;
599
- }
600
-
601
- if (element.nodeName.toLowerCase() === 'svg') {
602
- const parentGroup = element.closest('g');
603
- if (parentGroup && parentGroup.querySelector('title')) {
604
- customConsoleWarn('Parent group element has a <title>, skipping flagging of svg.');
605
- return false;
581
+
582
+ if (element.dataset.flagged === 'true') {
583
+ customConsoleWarn("Element is already flagged.");
584
+ return false;
606
585
  }
607
- }
608
-
609
- if (element.nodeName.toLowerCase() === 'button') {
610
- const hasAccessibleLabelForButton =
611
- hasAccessibleLabel(element) || isAccessibleText(element.textContent);
612
- if (hasAccessibleLabelForButton) {
613
- customConsoleWarn('Button has an accessible label, skipping flagging.');
614
- return false;
586
+
587
+ // If an ancestor element is flagged, do not flag this element
588
+ if (element.closest('[data-flagged="true"]')) {
589
+ customConsoleWarn("An ancestor element is already flagged.");
590
+ return false;
615
591
  }
616
-
617
- const hasSvgChildWithoutLabel = Array.from(element.children).some(
618
- child => child.nodeName.toLowerCase() === 'svg' && !hasAccessibleLabel(child),
619
- );
620
- if (hasSvgChildWithoutLabel) {
621
- customConsoleWarn('Flagging button with child SVG lacking accessible label.');
622
- return true;
592
+
593
+ // Skip elements that are not visible (e.g., display:none)
594
+ const computedStyle = element.ownerDocument.defaultView.getComputedStyle(element);
595
+ if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden' || element.offsetParent === null) {
596
+ customConsoleWarn("Element is not visible, skipping flagging.");
597
+ return false;
623
598
  }
624
- }
625
-
626
- if (
627
- element instanceof HTMLInputElement &&
628
- // element.nodeName.toLowerCase() === 'input' &&
629
- element.type === 'image' &&
630
- !hasAccessibleLabel(element)
631
- ) {
632
- customConsoleWarn("Flagging <input type='image'> without accessible label.");
633
- return true;
634
- }
635
-
636
- if (element.nodeName.toLowerCase() === 'a') {
637
- const img = element.querySelector('img');
638
-
639
- // Log to verify visibility and pointer checks
640
- customConsoleWarn('Processing <a> element.');
641
-
642
- // Ensure this <a> does not have an accessible label
643
- const linkHasAccessibleLabel = hasAccessibleLabel(element);
644
-
645
- // Ensure the <img> inside <a> does not have an accessible label
646
- const imgHasAccessibleLabel = img ? hasAccessibleLabel(img) : false;
599
+
600
+ // Skip empty <div> or <span> elements without any accessible text or children with accessible labels, unless they have a pointer cursor
601
+ if ((element.nodeName.toLowerCase() === 'div' || element.nodeName.toLowerCase() === 'span') &&
602
+ element.children.length === 0 && getTextContent(element).trim().length === 0) {
603
+
604
+ if (!hasPointerCursor(element)) {
605
+ customConsoleWarn("Empty div or span without accessible text and without pointer cursor, skipping flagging.");
606
+ return false;
607
+ }
608
+
609
+ // **New background-image check**
610
+ const backgroundImage = window.getComputedStyle(element).getPropertyValue('background-image');
611
+ if (backgroundImage && backgroundImage !== 'none') {
612
+ customConsoleWarn("Element has a background image.");
613
+
614
+ // Check if the element has accessible labels or text content
615
+ if (!hasAccessibleLabel(element) && !hasChildWithAccessibleText(element) && !isAccessibleText(getTextContent(element))) {
616
+ customConsoleWarn("Flagging element with background image but without accessible label or text.");
617
+ return true; // Flag the element
618
+ } else {
619
+ customConsoleWarn("Element with background image has accessible label or text, skipping flagging.");
620
+ return false; // Do not flag
621
+ }
622
+ }
623
+
624
+ // **Proceed with ancestor traversal if no background image is found**
625
+ // Traverse ancestors to check for interactive elements with accessible labels
626
+ let ancestor = element.parentElement;
627
+ let depth = 0;
628
+ const maxDepth = 4; // Limit the depth to prevent skipping elements incorrectly
629
+ while (ancestor && depth < maxDepth) {
630
+ // Determine if ancestor is interactive
631
+ const isAncestorInteractive = hasPointerCursor(ancestor) ||
632
+ ancestor.hasAttribute('onclick') ||
633
+ ancestor.hasAttribute('role') ||
634
+ (ancestor.hasAttribute('tabindex') && ancestor.getAttribute('tabindex') !== '-1') ||
635
+ ancestor.hasAttribute('jsaction') ||
636
+ ancestor.hasAttribute('jscontroller');
637
+
638
+ if (isAncestorInteractive) {
639
+ // Check if ancestor has accessible label or text content
640
+ if (hasAccessibleLabel(ancestor) || isAccessibleText(getTextContent(ancestor)) || hasChildWithAccessibleText(ancestor)) {
641
+ customConsoleWarn("Ancestor interactive element has accessible label or text content, skipping flagging.");
642
+ return false;
643
+ } else {
644
+ // Ancestor is interactive but lacks accessible labeling
645
+ customConsoleWarn("Ancestor interactive element lacks accessible label, continue flagging.");
646
+ // Do not skip flagging
647
+ }
648
+ }
649
+ ancestor = ancestor.parentElement;
650
+ depth++;
651
+ }
652
+
653
+ if (hasAccessibleLabel(element) || isAccessibleText(getTextContent(element)) || hasChildWithAccessibleText(element))
654
+ {
655
+ customConsoleWarn("Not Flagging clickable div or span with pointer cursor with accessible text.");
656
+ return false;
657
+ }
647
658
 
648
- // Log to verify if <img> has accessible label
649
- if (img) {
650
- customConsoleWarn(`Found <img> inside <a>. Accessible label: ${imgHasAccessibleLabel}`);
651
- } else {
652
- customConsoleWarn('No <img> found inside <a>.');
659
+ // If no interactive ancestor with accessible label is found, flag the element
660
+ customConsoleWarn("Flagging clickable div or span with pointer cursor and no accessible text.");
661
+ return true;
653
662
  }
654
-
655
- // Flag if both <a> and <img> inside lack accessible labels
656
- if (!linkHasAccessibleLabel && img && !imgHasAccessibleLabel) {
657
- customConsoleWarn('Flagging <a> with inaccessible <img>.');
658
- return true;
663
+
664
+ // Skip elements with role="menuitem" and ensure accessibility label for any nested elements
665
+ if (element.getAttribute('role') === 'menuitem') {
666
+ if (hasChildWithAccessibleText(element)) {
667
+ customConsoleWarn("Menuitem element has child with accessible text, skipping flagging.");
668
+ return false;
669
+ }
659
670
  }
660
-
661
- // Skip flagging if <a> has an accessible label or all children are accessible
662
- if (linkHasAccessibleLabel || hasAllChildrenAccessible(element)) {
663
- customConsoleWarn('Hyperlink has an accessible label, skipping flagging.');
664
- return false;
671
+
672
+ // Check if the parent element has an accessible label
673
+ const parent = element.closest('[aria-label], [role="button"], [role="link"], a, button');
674
+
675
+ if (parent && (hasAccessibleLabel(parent) || hasChildWithAccessibleText(parent))) {
676
+ customConsoleWarn("Parent element has an accessible label or accessible child, skipping flagging.");
677
+ return false;
665
678
  }
666
- }
667
-
668
- // Modify this section for generic elements
669
- if (['span', 'div', 'icon', 'svg', 'button'].includes(element.nodeName.toLowerCase())) {
670
- if (element.nodeName.toLowerCase() === 'icon' || element.nodeName.toLowerCase() === 'svg') {
671
- // Check if the element has an accessible label or if it has a sibling, parent, or summary/related element that provides an accessible label
672
- if (
673
- !hasAccessibleLabel(element) &&
674
- !hasSiblingOrParentAccessibleLabel(element) &&
675
- !hasSummaryOrDetailsLabel(element) &&
676
- element.getAttribute('focusable') !== 'false'
677
- ) {
678
- customConsoleWarn('Flagging icon or svg without accessible label.');
679
- return true;
680
- }
681
- return false;
679
+
680
+ // Skip flagging if any child has an accessible label (e.g., <img alt="...">
681
+ if (hasAllChildrenAccessible(element)) {
682
+ customConsoleWarn("Element has child nodes with accessible text.");
683
+ return false;
682
684
  }
683
-
684
- if (element.textContent.trim().length > 0) {
685
- customConsoleWarn('Element has valid text content.');
686
- return false;
685
+
686
+ // Check if the <a> element has all children accessible
687
+ if (element.nodeName.toLowerCase() === 'a' && hasAllChildrenAccessible(element)) {
688
+ customConsoleWarn("Hyperlink has all children with accessible labels, skipping flagging.");
689
+ return false;
687
690
  }
688
-
689
- if (
690
- element.hasAttribute('aria-label') &&
691
- element.getAttribute('aria-label').trim().length > 0
692
- ) {
693
- customConsoleWarn('Element has an aria-label attribute, skipping flagging.');
694
- return false;
691
+
692
+ if (element.hasAttribute('tabindex') && element.getAttribute('tabindex') === '-1') {
693
+ customConsoleWarn("Element has tabindex='-1'.");
694
+ return false;
695
695
  }
696
- }
697
-
698
- if (element.nodeName.toLowerCase() === 'div') {
699
- const flaggedChild = Array.from(element.children).some(
700
- (child: HTMLElement) => child.dataset.flagged === 'true',
696
+
697
+ const childWithTabindexNegativeOne = Array.from(element.children).some(child =>
698
+ child.hasAttribute('tabindex') && child.getAttribute('tabindex') === '-1'
701
699
  );
702
- if (flaggedChild) {
703
- customConsoleWarn('Div contains a flagged child, flagging only outermost element.');
704
- return false;
700
+ if (childWithTabindexNegativeOne) {
701
+ customConsoleWarn("Element has a child with tabindex='-1'.");
702
+ return false;
705
703
  }
706
-
707
- // Update this condition to include hasChildWithAccessibleText
708
- if (element.textContent.trim().length > 0 || hasChildWithAccessibleText(element)) {
709
- customConsoleWarn('Div has valid text content or child with accessible text.');
710
- return false;
704
+
705
+ if (landmarkElements.includes(element.nodeName.toLowerCase())) {
706
+ customConsoleWarn("Element is a landmark element.");
707
+ return false;
711
708
  }
712
-
713
- const img = element.querySelector('img');
714
- if (img) {
715
- const altText = img.getAttribute('alt');
716
- const ariaLabel = img.getAttribute('aria-label');
717
- const ariaLabelledByText = getAriaLabelledByText(img);
718
- if (altText !== null || ariaLabel || ariaLabelledByText) {
719
- customConsoleWarn(
720
- 'Div contains an accessible img or an img with an alt attribute (even if empty).',
721
- );
709
+
710
+ // Prevent flagging <svg> or <icon> if a sibling or parent has an accessible label or if it is part of a button-like element
711
+ if ((element.nodeName.toLowerCase() === 'svg' || element.nodeName.toLowerCase() === 'icon') && (element.getAttribute('focusable') === 'false' || hasSiblingOrParentAccessibleLabel(element) || element.closest('[role="button"]') || element.closest('button'))) {
712
+ customConsoleWarn("Sibling or parent element has an accessible label or svg is part of a button, skipping flagging of svg or icon.");
722
713
  return false;
723
- }
724
714
  }
725
-
726
- const svg = element.querySelector('svg');
727
- if (svg) {
728
- if (
729
- hasPointerCursor(element) &&
730
- !hasAccessibleLabel(svg) &&
731
- !hasSummaryOrDetailsLabel(svg) &&
732
- svg.getAttribute('focusable') !== 'false'
733
- ) {
734
- customConsoleWarn('Flagging clickable div with SVG without accessible label.');
735
- return true;
736
- }
715
+
716
+ if (element.nodeName.toLowerCase() === 'svg') {
717
+ const parentGroup = element.closest('g');
718
+ if (parentGroup && parentGroup.querySelector('title')) {
719
+ customConsoleWarn("Parent group element has a <title>, skipping flagging of svg.");
720
+ return false;
721
+ }
737
722
  }
738
-
739
- if (
740
- hasPointerCursor(element) &&
741
- !hasAccessibleLabel(element) &&
742
- !isAccessibleText(element.textContent)
743
- ) {
744
- customConsoleWarn('Clickable div without accessible label or text content.');
745
- return true;
723
+
724
+ if (element.nodeName.toLowerCase() === 'button') {
725
+ const hasAccessibleLabelForButton = hasAccessibleLabel(element) || isAccessibleText(getTextContent(element));
726
+ if (hasAccessibleLabelForButton) {
727
+ customConsoleWarn("Button has an accessible label, skipping flagging.");
728
+ return false;
729
+ }
730
+
731
+ const hasSvgChildWithoutLabel = Array.from(element.children).some(child => child.nodeName.toLowerCase() === 'svg' && !hasAccessibleLabel(child));
732
+ if (hasSvgChildWithoutLabel) {
733
+ customConsoleWarn("Flagging button with child SVG lacking accessible label.");
734
+ return true;
735
+ }
746
736
  }
737
+
738
+ if (element.nodeName.toLowerCase() === 'input' && (element as HTMLInputElement).type === 'image' && !hasAccessibleLabel(element)) {
739
+ customConsoleWarn("Flagging <input type='image'> without accessible label.");
740
+ return true;
747
741
  }
748
-
749
- if (
750
- element.nodeName.toLowerCase() === 'img' ||
751
- element.nodeName.toLowerCase() === 'picture'
752
- ) {
753
- const imgElement =
754
- element.nodeName.toLowerCase() === 'picture' ? element.querySelector('img') : element;
755
- const altText = imgElement.getAttribute('alt');
756
- const ariaLabel = imgElement.getAttribute('aria-label');
757
- const ariaLabelledByText = getAriaLabelledByText(imgElement);
758
-
759
- if (!allowNonClickableFlagging) {
760
- if (
761
- !imgElement.closest('a') &&
762
- !imgElement.closest('button') &&
763
- !hasPointerCursor(imgElement) &&
764
- !(altText !== null) &&
765
- !(ariaLabel && ariaLabel.trim().length > 0) &&
766
- !(ariaLabelledByText && ariaLabelledByText.length > 0)
767
- ) {
768
- customConsoleWarn('Non-clickable image ignored.');
742
+
743
+ if (element.nodeName.toLowerCase() === 'a') {
744
+ const img = element.querySelector('img');
745
+
746
+ // Log to verify visibility and pointer checks
747
+ customConsoleWarn("Processing <a> element.");
748
+
749
+ // Ensure this <a> does not have an accessible label
750
+ const linkHasAccessibleLabel = hasAccessibleLabel(element);
751
+
752
+ // Ensure the <img> inside <a> does not have an accessible label
753
+ const imgHasAccessibleLabel = img ? hasAccessibleLabel(img) : false;
754
+
755
+ // Log to verify if <img> has accessible label
756
+ if (img) {
757
+ customConsoleWarn("Found <img> inside <a>. Accessible label: " + imgHasAccessibleLabel);
758
+ } else {
759
+ customConsoleWarn("No <img> found inside <a>.");
760
+ }
761
+
762
+
763
+ // Skip flagging if <a> has an accessible label or all children are accessible
764
+ if (linkHasAccessibleLabel || hasChildNotANewInteractWithAccessibleText(element)) {
765
+ customConsoleWarn("Hyperlink has an accessible label, skipping flagging.");
766
+ return false;
767
+ }
768
+
769
+ // Flag if both <a> and <img> inside lack accessible labels
770
+ if (!linkHasAccessibleLabel && img && !imgHasAccessibleLabel) {
771
+ customConsoleWarn("Flagging <a> with inaccessible <img>.");
772
+ return true;
773
+ }
774
+
775
+ if (!linkHasAccessibleLabel)
776
+ {
777
+ customConsoleWarn("Flagging <a> with no accessible label");
778
+ return true;
779
+ }
780
+ }
781
+
782
+ // Modify this section for generic elements
783
+ if (['span', 'div', 'icon', 'svg', 'button'].includes(element.nodeName.toLowerCase())) {
784
+ if (element.nodeName.toLowerCase() === 'icon' || element.nodeName.toLowerCase() === 'svg') {
785
+ // Check if the element has an accessible label or if it has a sibling, parent, or summary/related element that provides an accessible label
786
+ if (!hasAccessibleLabel(element) && !hasSiblingOrParentAccessibleLabel(element) && !hasSummaryOrDetailsLabel(element) && element.getAttribute('focusable') !== 'false') {
787
+ customConsoleWarn("Flagging icon or svg without accessible label.");
788
+ return true;
789
+ }
790
+ return false;
791
+ }
792
+
793
+ if (getTextContent(element).trim().length > 0) {
794
+ customConsoleWarn("Element has valid text content.");
795
+ return false;
796
+ }
797
+
798
+ if (element.hasAttribute('aria-label') && element.getAttribute('aria-label').trim().length > 0) {
799
+ customConsoleWarn("Element has an aria-label attribute, skipping flagging.");
800
+ return false;
801
+ }
802
+ }
803
+
804
+ if (element.nodeName.toLowerCase() === 'div') {
805
+ const flaggedChild = Array.from(element.children).some(child => (child as HTMLElement).dataset.flagged === 'true');
806
+ if (flaggedChild) {
807
+ customConsoleWarn("Div contains a flagged child, flagging only outermost element.");
808
+ return false;
809
+ }
810
+
811
+ // Update this condition to include hasChildWithAccessibleText
812
+ if (getTextContent(element).trim().length > 0 || hasChildWithAccessibleText(element)) {
813
+ customConsoleWarn("Div has valid text content or child with accessible text.");
814
+ return false;
815
+ }
816
+
817
+ const img = element.querySelector('img');
818
+ if (img) {
819
+ const altText = img.getAttribute('alt');
820
+ const ariaLabel = img.getAttribute('aria-label');
821
+ const ariaLabelledByText = getAriaLabelledByText(img);
822
+ if (altText !== null || ariaLabel || ariaLabelledByText) {
823
+ customConsoleWarn("Div contains an accessible img or an img with an alt attribute (even if empty).");
824
+ return false;
825
+ }
826
+ }
827
+
828
+ const svg = element.querySelector('svg');
829
+ if (svg) {
830
+ if (hasPointerCursor(element) && !hasAccessibleLabel(svg) && !hasSummaryOrDetailsLabel(svg) && svg.getAttribute('focusable') !== 'false') {
831
+ customConsoleWarn("Flagging clickable div with SVG without accessible label.");
832
+ return true;
833
+ }
834
+ }
835
+
836
+ if (hasPointerCursor(element) && !hasAccessibleLabel(element) && !isAccessibleText(getTextContent(element))) {
837
+ customConsoleWarn("Clickable div without accessible label or text content.");
838
+ return true;
839
+ }
840
+ }
841
+
842
+ if (element.nodeName.toLowerCase() === 'img' || element.nodeName.toLowerCase() === 'picture') {
843
+ const imgElement = element.nodeName.toLowerCase() === 'picture' ? element.querySelector('img') : element;
844
+ const altText = imgElement.getAttribute('alt');
845
+ const ariaLabel = imgElement.getAttribute('aria-label');
846
+ const ariaLabelledByText = getAriaLabelledByText(imgElement);
847
+
848
+ if (!allowNonClickableFlagging) {
849
+ if (!imgElement.closest('a') && !imgElement.closest('button') && !hasPointerCursor(imgElement) && !(altText !== null) && !(ariaLabel && ariaLabel.trim().length > 0) && !(ariaLabelledByText && ariaLabelledByText.length > 0)) {
850
+ customConsoleWarn("Non-clickable image ignored.");
851
+ return false;
852
+ }
853
+ }
854
+
855
+ if (!imgElement.closest('a') && !imgElement.closest('button') && !(altText !== null) && !(ariaLabel && ariaLabel.trim().length > 0) && !(ariaLabelledByText && ariaLabelledByText.length > 0)) {
856
+ customConsoleWarn("Flagging img or picture without accessible label.");
857
+ return true;
858
+ }
859
+ }
860
+
861
+ // Additional check to skip divs with empty children or child-child elements
862
+ const areAllDescendantsEmpty = Array.from(element.querySelectorAll('*')).every(child => getTextContent(child).trim().length === 0 && !hasAccessibleLabel(child));
863
+ if (element.nodeName.toLowerCase() === 'div' && areAllDescendantsEmpty) {
864
+ customConsoleWarn("Div with empty descendants, skipping flagging.");
769
865
  return false;
770
- }
771
866
  }
772
-
773
- if (
774
- !imgElement.closest('a') &&
775
- !imgElement.closest('button') &&
776
- !(altText !== null) &&
777
- !(ariaLabel && ariaLabel.trim().length > 0) &&
778
- !(ariaLabelledByText && ariaLabelledByText.length > 0)
779
- ) {
780
- customConsoleWarn('Flagging img or picture without accessible label.');
781
- return true;
867
+
868
+ if (hasCSSContent(element)) {
869
+ customConsoleWarn("Element has CSS ::before or ::after content, skipping flagging.");
870
+ return false;
782
871
  }
783
- }
784
-
785
- // Additional check to skip divs with empty children or child-child elements
786
- const areAllDescendantsEmpty = Array.from(element.querySelectorAll('*')).every(
787
- child => child.textContent.trim().length === 0 && !hasAccessibleLabel(child),
788
- );
789
- if (element.nodeName.toLowerCase() === 'div' && areAllDescendantsEmpty) {
790
- customConsoleWarn('Div with empty descendants, skipping flagging.');
791
- return false;
792
- }
793
-
794
- if (hasCSSContent(element)) {
795
- customConsoleWarn('Element has CSS ::before or ::after content, skipping flagging.');
796
- return false;
797
- }
798
-
799
- return false; // Default case: do not flag
872
+
873
+ return false; // Default case: do not flag
800
874
  }
801
875
 
802
876
  function flagElements() {