From eb2dbb61a3543bf9cfaeaedbb8c09601f2e9c613 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 24 Jun 2025 00:20:41 -0400 Subject: [PATCH 01/14] added: landmark and landmark location to database --- accessibility-checker.php | 2 +- admin/class-insert-rule-data.php | 68 ++++++++++++++------------- admin/class-update-database.php | 2 + includes/classes/class-rest-api.php | 5 +- src/pageScanner/index.js | 72 +++++++++++++++++++++++++++-- 5 files changed, 112 insertions(+), 37 deletions(-) diff --git a/accessibility-checker.php b/accessibility-checker.php index 9f0d8f7d7..36ab4a0f7 100755 --- a/accessibility-checker.php +++ b/accessibility-checker.php @@ -40,7 +40,7 @@ // Current database version. if ( ! defined( 'EDAC_DB_VERSION' ) ) { - define( 'EDAC_DB_VERSION', '1.0.3' ); + define( 'EDAC_DB_VERSION', '1.0.5' ); } // Plugin Folder Path. diff --git a/admin/class-insert-rule-data.php b/admin/class-insert-rule-data.php index db15a3816..2e5ba15dd 100644 --- a/admin/class-insert-rule-data.php +++ b/admin/class-insert-rule-data.php @@ -21,15 +21,17 @@ class Insert_Rule_Data { * * @since 1.10.0 * - * @param object $post The post object. Must have a valid ID. - * @param string $rule The rule. - * @param string $ruletype The rule type. - * @param string $rule_obj The object. + * @param object $post The post object. Must have a valid ID. + * @param string $rule The rule. + * @param string $ruletype The rule type. + * @param string $rule_obj The object. + * @param string|null $landmark The landmark type (main, header, footer, nav), optional. + * @param string|null $landmark_selector The landmark selector, optional. * * @return void|int|\WP_Error The ID of the inserted record, void if no * record was inserted or a WP_Error if the insert failed. */ - public function insert( object $post, string $rule, string $ruletype, string $rule_obj ) { + public function insert( object $post, string $rule, string $ruletype, string $rule_obj, $landmark = null, $landmark_selector = null ) { if ( ! isset( $post->ID, $post->post_type ) || empty( $rule ) @@ -44,19 +46,21 @@ public function insert( object $post, string $rule, string $ruletype, string $ru // set up rule data array. $rule_data = [ - 'postid' => $post->ID, - 'siteid' => get_current_blog_id(), - 'type' => $post->post_type, - 'rule' => $rule, - 'ruletype' => $ruletype, - 'object' => esc_attr( $rule_obj ), - 'recordcheck' => 1, - 'user' => get_current_user_id(), - 'ignre' => 0, - 'ignre_user' => null, - 'ignre_date' => null, - 'ignre_comment' => null, - 'ignre_global' => 0, + 'postid' => $post->ID, + 'siteid' => get_current_blog_id(), + 'type' => $post->post_type, + 'landmark' => $landmark, + 'landmark_selector' => $landmark_selector, + 'rule' => $rule, + 'ruletype' => $ruletype, + 'object' => esc_attr( $rule_obj ), + 'recordcheck' => 1, + 'user' => get_current_user_id(), + 'ignre' => 0, + 'ignre_user' => null, + 'ignre_date' => null, + 'ignre_comment' => null, + 'ignre_global' => 0, ]; // return if revision. @@ -125,19 +129,21 @@ public function insert( object $post, string $rule, string $ruletype, string $ru // the data is still as valid as it was when it was first set. // Sanitize the filtered data. $rule_data_sanitized = [ - 'postid' => absint( $rule_data['postid'] ), - 'siteid' => absint( $rule_data['siteid'] ), - 'type' => sanitize_text_field( $rule_data['type'] ), - 'rule' => sanitize_text_field( $rule_data['rule'] ), - 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ), - 'object' => esc_attr( $rule_data['object'] ), - 'recordcheck' => absint( $rule_data['recordcheck'] ), - 'user' => absint( $rule_data['user'] ), - 'ignre' => absint( $rule_data['ignre'] ), - 'ignre_user' => isset( $rule_data['ignre_user'] ) ? absint( $rule_data['ignre_user'] ) : null, - 'ignre_date' => isset( $rule_data['ignre_date'] ) ? sanitize_text_field( $rule_data['ignre_date'] ) : null, - 'ignre_comment' => isset( $rule_data['ignre_comment'] ) ? sanitize_text_field( $rule_data['ignre_comment'] ) : null, - 'ignre_global' => absint( $rule_data['ignre_global'] ), + 'postid' => absint( $rule_data['postid'] ), + 'siteid' => absint( $rule_data['siteid'] ), + 'type' => sanitize_text_field( $rule_data['type'] ), + 'landmark' => isset( $rule_data['landmark'] ) ? sanitize_text_field( $rule_data['landmark'] ) : null, + 'landmark_selector' => isset( $rule_data['landmark_selector'] ) ? sanitize_text_field( $rule_data['landmark_selector'] ) : null, + 'rule' => sanitize_text_field( $rule_data['rule'] ), + 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ), + 'object' => esc_attr( $rule_data['object'] ), + 'recordcheck' => absint( $rule_data['recordcheck'] ), + 'user' => absint( $rule_data['user'] ), + 'ignre' => absint( $rule_data['ignre'] ), + 'ignre_user' => isset( $rule_data['ignre_user'] ) ? absint( $rule_data['ignre_user'] ) : null, + 'ignre_date' => isset( $rule_data['ignre_date'] ) ? sanitize_text_field( $rule_data['ignre_date'] ) : null, + 'ignre_comment' => isset( $rule_data['ignre_comment'] ) ? sanitize_text_field( $rule_data['ignre_comment'] ) : null, + 'ignre_global' => absint( $rule_data['ignre_global'] ), ]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Using direct query for adding data to database. diff --git a/admin/class-update-database.php b/admin/class-update-database.php index 894882373..10bd6f30d 100644 --- a/admin/class-update-database.php +++ b/admin/class-update-database.php @@ -48,6 +48,8 @@ public function edac_update_database() { postid bigint(20) NOT NULL, siteid text NOT NULL, type text NOT NULL, + landmark varchar(20) NULL, + landmark_selector text NULL, rule text NOT NULL, ruletype text NOT NULL, object mediumtext NOT NULL, diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index fb4fe4fbe..1d5247da3 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -380,7 +380,10 @@ public function set_post_scan_results( $request ) { */ do_action( 'edac_before_rule', $post_id, $actual_rule_id, 'js' ); - ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html ); + $landmark = isset( $violation['landmark'] ) ? $violation['landmark'] : null; + $landmark_selector = isset( $violation['landmarkSelector'] ) ? $violation['landmarkSelector'] : null; + + ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html, $landmark, $landmark_selector ); /** * Fires after a rule is run against the content. diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js index 5456a1c23..3dfe4a981 100644 --- a/src/pageScanner/index.js +++ b/src/pageScanner/index.js @@ -10,6 +10,60 @@ import { getPageDensity } from './helpers/density'; const SCAN_TIMEOUT_IN_SECONDS = 30; +// Landmark tags for semantic regions +const LANDMARK_TAGS = [ 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE' ]; +const LANDMARK_ROLES = [ + 'main', + 'navigation', + 'banner', + 'contentinfo', + 'complementary', +]; + +function getLandmarkForSelector( selector ) { + const el = document.querySelector( selector ); + if ( ! el ) { + return { type: null, selector: null }; + } + let current = el; + while ( current && current !== document.body ) { + if ( LANDMARK_TAGS.includes( current.tagName ) ) { + return { type: current.tagName.toLowerCase(), selector: getElementSelector( current ) }; + } + if ( current.hasAttribute( 'role' ) ) { + const role = current.getAttribute( 'role' ).toLowerCase(); + if ( LANDMARK_ROLES.includes( role ) ) { + return { type: role, selector: getElementSelector( current ) }; + } + } + current = current.parentElement; + } + return { type: null, selector: null }; +} + +// Helper to get a unique CSS selector for an element +function getElementSelector( element ) { + if ( ! element ) { + return null; + } + if ( element.id ) { + return `#${ element.id }`; + } + const path = []; + while ( element && element.nodeType === Node.ELEMENT_NODE && element !== document.body ) { + let selector = element.nodeName.toLowerCase(); + if ( element.className ) { + const classes = element.className.trim().split( /\s+/ ).join( '.' ); + selector += `.${ classes }`; + } + const siblingIndex = Array.from( element.parentNode.children ).indexOf( element ) + 1; + selector += `:nth-child(${ siblingIndex })`; + path.unshift( selector ); + element = element.parentElement; + } + return path.length ? path.join( ' > ' ) : null; +} + // Read the data passed from the parent document. const body = document.querySelector( 'body' ); const iframeId = body.getAttribute( 'data-iframe-id' ); @@ -58,12 +112,17 @@ const scan = async ( //Build an array of the dom selectors and ruleIDs for violations/failed tests item.violations.forEach( ( violation ) => { if ( violation.result === 'failed' ) { + const selector = violation.node.selector; + const html = document.querySelector( selector )?.outerHTML; + const landmark = getLandmarkForSelector( selector ); violations.push( { - selector: violation.node.selector, - html: document.querySelector( violation.node.selector ).outerHTML, + selector, + html, ruleId: item.id, impact: item.impact, tags: item.tags, + landmark: landmark.type, + landmarkSelector: landmark.selector, } ); } } ); @@ -71,12 +130,17 @@ const scan = async ( // Handle incomplete results for form-field-multiple-labels only. if ( item.id === 'form-field-multiple-labels' ) { // Allow incomplete results for this rule. item.incomplete.forEach( ( incompleteItem ) => { + const selector = incompleteItem.node.selector; + const html = document.querySelector( selector )?.outerHTML; + const landmark = getLandmarkForSelector( selector ); violations.push( { - selector: incompleteItem.node.selector, - html: document.querySelector( incompleteItem.node.selector ).outerHTML, + selector, + html, ruleId: item.id, impact: item.impact, tags: item.tags, + landmark: landmark.type, + landmarkSelector: landmark.selector, } ); } ); } From 906f46b9eee9b7aa9b9e7d6cf972e6f9edafbf1a Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 24 Jun 2025 00:28:36 -0400 Subject: [PATCH 02/14] updated: test --- tests/phpunit/Admin/InsertRuleDataTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/Admin/InsertRuleDataTest.php b/tests/phpunit/Admin/InsertRuleDataTest.php index a2cef58c3..3e3d7ead2 100644 --- a/tests/phpunit/Admin/InsertRuleDataTest.php +++ b/tests/phpunit/Admin/InsertRuleDataTest.php @@ -37,6 +37,8 @@ public function setUp(): void { ignre_user bigint(20) NULL, ignre_date timestamp NULL, ignre_comment mediumtext NULL, + landmark varchar(20) NULL, + landmark_selector text NULL, UNIQUE KEY id (id), KEY postid_index (postid) ) $charset_collate;"; From 5baaf7092dd49c01fd160e8c47cd8ad1344e9fb1 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 15:48:31 -0400 Subject: [PATCH 03/14] added: support for conditional landmark tags and roles in landmark detection --- src/pageScanner/index.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js index 3dfe4a981..936149783 100644 --- a/src/pageScanner/index.js +++ b/src/pageScanner/index.js @@ -20,6 +20,10 @@ const LANDMARK_ROLES = [ 'complementary', ]; +// Conditional landmark tags that only become landmarks when they have accessible names +const CONDITIONAL_LANDMARK_TAGS = [ 'SECTION', 'ARTICLE', 'FORM' ]; +const CONDITIONAL_LANDMARK_ROLES = [ 'region', 'article', 'form' ]; + function getLandmarkForSelector( selector ) { const el = document.querySelector( selector ); if ( ! el ) { @@ -27,15 +31,37 @@ function getLandmarkForSelector( selector ) { } let current = el; while ( current && current !== document.body ) { + // Check unconditional landmark tags if ( LANDMARK_TAGS.includes( current.tagName ) ) { return { type: current.tagName.toLowerCase(), selector: getElementSelector( current ) }; } + + // Check conditional landmark tags (require accessible name) + if ( + CONDITIONAL_LANDMARK_TAGS.includes( current.tagName ) && + ( current.hasAttribute( 'aria-label' ) || current.hasAttribute( 'aria-labelledby' ) ) + ) { + return { type: current.tagName.toLowerCase(), selector: getElementSelector( current ) }; + } + + // Check roles if ( current.hasAttribute( 'role' ) ) { const role = current.getAttribute( 'role' ).toLowerCase(); + + // Check unconditional landmark roles if ( LANDMARK_ROLES.includes( role ) ) { return { type: role, selector: getElementSelector( current ) }; } + + // Check conditional landmark roles (require accessible name) + if ( + CONDITIONAL_LANDMARK_ROLES.includes( role ) && + ( current.hasAttribute( 'aria-label' ) || current.hasAttribute( 'aria-labelledby' ) ) + ) { + return { type: role, selector: getElementSelector( current ) }; + } } + current = current.parentElement; } return { type: null, selector: null }; From fb4d1e797a52008dab5d1be1c6c4df57d50e3c69 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 16:19:44 -0400 Subject: [PATCH 04/14] added: include landmark data in AJAX response and updated layout for landmark display --- admin/class-ajax.php | 14 +++++++++++++- src/admin/sass/accessibility-checker-admin.scss | 4 ++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 349e9c98c..50b317626 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -305,7 +305,7 @@ function ( $a, $b ) { foreach ( $rules as $rule ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for interacting with custom database, safe variable used for table name, caching not required for one time operation. - $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A ); + $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global, landmark FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A ); $count_classes = ( 'error' === $rule['rule_type'] ) ? ' edac-details-rule-count-error' : ' edac-details-rule-count-warning'; $count_classes .= ( 0 !== $rule['count'] ) ? ' active' : ''; @@ -406,6 +406,8 @@ function ( $a, $b ) { $html .= ob_get_clean(); } + + $html .= '
'; + $html .= '
'; + + $landmark = isset( $row['landmark'] ) ? esc_html( $row['landmark'] ) : ''; + $html .= $landmark ? $landmark : ''; + + $html .= '
'; + $html .= '
'; if ( ! isset( $rule['viewable'] ) || $rule['viewable'] ) { diff --git a/src/admin/sass/accessibility-checker-admin.scss b/src/admin/sass/accessibility-checker-admin.scss index d8e57ae83..8246e2de4 100644 --- a/src/admin/sass/accessibility-checker-admin.scss +++ b/src/admin/sass/accessibility-checker-admin.scss @@ -621,7 +621,7 @@ color: variables.$color-white; background-color: variables.$color-dark-gray; display: grid; - grid-template-columns: auto 125px 125px; + grid-template-columns: auto 125px 125px 125px; &-label { padding: 3px; @@ -631,7 +631,7 @@ &-record { display: grid; - grid-template-columns: auto 125px 125px; + grid-template-columns: auto 125px 125px 125px; &-cell { padding: 10px; From 2fd3252f0047fdd72038d5443bee79f04978bc35 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 18:10:51 -0400 Subject: [PATCH 05/14] added: enhance landmark handling by including landmark selector and improving highlight functionality --- admin/class-ajax.php | 25 +++- src/frontendHighlighterApp/index.js | 192 ++++++++++++++++++++++++++++ src/pageScanner/index.js | 87 +++++++++++-- 3 files changed, 293 insertions(+), 11 deletions(-) diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 50b317626..fada65a48 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -305,7 +305,7 @@ function ( $a, $b ) { foreach ( $rules as $rule ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for interacting with custom database, safe variable used for table name, caching not required for one time operation. - $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global, landmark FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A ); + $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global, landmark, landmark_selector FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A ); $count_classes = ( 'error' === $rule['rule_type'] ) ? ' edac-details-rule-count-error' : ' edac-details-rule-count-warning'; $count_classes .= ( 0 !== $rule['count'] ) ? ' active' : ''; @@ -468,8 +468,27 @@ function ( $a, $b ) { $html .= '
'; - $landmark = isset( $row['landmark'] ) ? esc_html( $row['landmark'] ) : ''; - $html .= $landmark ? $landmark : ''; + $landmark = isset( $row['landmark'] ) ? esc_html( $row['landmark'] ) : ''; + $landmark_selector = isset( $row['landmark_selector'] ) ? $row['landmark_selector'] : ''; + + if ( $landmark && $landmark_selector ) { + $landmark_url = add_query_arg( + [ + 'edac_landmark' => base64_encode( $landmark_selector ), + 'edac_nonce' => wp_create_nonce( 'edac_highlight' ), + ], + get_the_permalink( $postid ) + ); + + // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main"). + $landmark_aria_label = sprintf( __( 'View %s landmark on website, opens a new window', 'accessibility-checker' ), $landmark ); + // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main"). + $html .= '' . $landmark . ''; + } elseif ( $landmark ) { + $html .= $landmark; + } else { + $html .= ''; + } $html .= '
'; diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index dc3eb474a..d8f404e05 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -32,6 +32,7 @@ class AccessibilityCheckerHighlight { this.fixes = null; this.currentButtonIndex = null; this.urlParameter = this.get_url_parameter( 'edac' ); + this.landmarkParameter = this.get_url_parameter( 'edac_landmark' ); this.currentIssueStatus = null; this.tooltips = []; this.panelControlsFocusTrap = createFocusTrap( '#' + this.panelControls.id, { @@ -97,6 +98,8 @@ class AccessibilityCheckerHighlight { // Open panel if a URL parameter exists if ( this.urlParameter ) { this.panelOpen( this.urlParameter ); + } else if ( this.landmarkParameter ) { + this.highlightLandmark( this.landmarkParameter ); } } @@ -242,6 +245,9 @@ class AccessibilityCheckerHighlight { buttons.forEach( ( button ) => { button.remove(); } ); + + // Clean up any landmark labels + this.removeLandmarkLabels(); } /** @@ -577,6 +583,9 @@ class AccessibilityCheckerHighlight { selectedElement.removeAttribute( 'class' ); } } ); + + // Clean up any landmark labels when highlights are removed + this.removeLandmarkLabels(); }; /** @@ -889,6 +898,189 @@ class AccessibilityCheckerHighlight { div.textContent = textContent; } + + /** + * This function highlights a landmark based on the selector. + * @param {string} encodedSelector Base64-encoded CSS selector for the landmark + */ + highlightLandmark( encodedSelector ) { + try { + // Decode the base64 selector + const selector = atob( encodedSelector ); + + // Find the landmark element using multiple strategies + let landmarkElement = null; + + try { + // Try the original selector first + landmarkElement = document.querySelector( selector ); + } catch ( error ) { + // Selector might be invalid, try fallbacks + } + + // If original selector failed, try some fallback strategies + if ( ! landmarkElement ) { + // Try common landmark selectors as fallbacks + const fallbackSelectors = [ + // Remove complex pseudo-selectors and try simpler versions + selector.replace( /:nth-child\(\d+\)/g, '' ).replace( /\s+>\s+/g, ' ' ), + // Try just the last part of the selector + selector.split( ' > ' ).pop(), + // Try without classes + selector.replace( /\.[^:\s>]+/g, '' ), + ]; + + for ( const fallback of fallbackSelectors ) { + if ( fallback && fallback.trim() ) { + try { + landmarkElement = document.querySelector( fallback.trim() ); + if ( landmarkElement ) { + break; + } + } catch ( e ) { + // Continue to next fallback + } + } + } + } + + if ( landmarkElement ) { + // Clean up any existing landmark labels first + this.removeLandmarkLabels(); + + // Add highlighting styles + landmarkElement.classList.add( 'edac-highlight-element-selected' ); + landmarkElement.classList.add( 'edac-landmark-highlight' ); + + // Create and add landmark type label + const landmarkType = this.getLandmarkType( landmarkElement ); + const landmarkLabel = document.createElement( 'div' ); + landmarkLabel.classList.add( 'edac-landmark-label' ); + landmarkLabel.textContent = `Landmark: ${ landmarkType }`; + landmarkLabel.setAttribute( 'aria-hidden', 'true' ); + landmarkLabel.style.cssText = ` + position: absolute; + background: #072446; + color: white; + padding: 4px 8px; + font-size: 12px; + font-weight: bold; + border-radius: 3px; + z-index: 999999; + pointer-events: none; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + `; + + // Position the label inside the top-left corner of the landmark + const rect = landmarkElement.getBoundingClientRect(); + landmarkLabel.style.left = ( rect.left + window.scrollX - 0 ) + 'px'; // 15px inside from left edge + landmarkLabel.style.top = ( rect.top + window.scrollY - 0 ) + 'px'; // 15px inside from top edge + + // Add label to the page + document.body.appendChild( landmarkLabel ); + + // Store reference for cleanup + landmarkElement.setAttribute( 'data-edac-landmark-label-id', Date.now() ); + landmarkLabel.setAttribute( 'data-edac-landmark-for', landmarkElement.getAttribute( 'data-edac-landmark-label-id' ) ); + + // Adjust for small elements + if ( landmarkElement.offsetWidth < 20 ) { + landmarkElement.classList.add( 'edac-highlight-element-selected-min-width' ); + } + + if ( landmarkElement.offsetHeight < 5 ) { + landmarkElement.classList.add( 'edac-highlight-element-selected-min-height' ); + } + + // Scroll to the landmark + landmarkElement.scrollIntoView( { block: 'center', behavior: 'smooth' } ); + + } else { + // Landmark element not found - silently fail + } + } catch ( error ) { + // Error highlighting landmark - silently fail + } + } + + /** + * Determines the landmark type of an element + * @param {HTMLElement} element The element to check + * @return {string} The landmark type (e.g., "Header", "Navigation", "Main") + */ + getLandmarkType( element ) { + // Check explicit ARIA role first + const role = element.getAttribute( 'role' ); + if ( role ) { + switch ( role.toLowerCase() ) { + case 'banner': + return 'Header'; + case 'navigation': + return 'Navigation'; + case 'main': + return 'Main'; + case 'complementary': + return 'Complementary'; + case 'contentinfo': + return 'Footer'; + case 'search': + return 'Search'; + case 'form': + return 'Form'; + case 'region': + return 'Region'; + default: + return role.charAt( 0 ).toUpperCase() + role.slice( 1 ); + } + } + + // Check semantic HTML elements + const tagName = element.tagName.toLowerCase(); + switch ( tagName ) { + case 'header': + return 'Header'; + case 'nav': + return 'Navigation'; + case 'main': + return 'Main'; + case 'aside': + return 'Complementary'; + case 'footer': + return 'Footer'; + case 'section': + // Check if section has accessible name + const hasAccessibleName = element.getAttribute( 'aria-label' ) || + element.getAttribute( 'aria-labelledby' ) || + element.querySelector( 'h1, h2, h3, h4, h5, h6' ); + return hasAccessibleName ? 'Region' : 'Section'; + case 'form': + // Check if form has accessible name + const formHasAccessibleName = element.getAttribute( 'aria-label' ) || + element.getAttribute( 'aria-labelledby' ); + return formHasAccessibleName ? 'Form' : 'Form (unlabeled)'; + default: + return 'Landmark'; + } + } + + /** + * Remove all landmark labels from the page + */ + removeLandmarkLabels() { + const landmarkLabels = document.querySelectorAll( '.edac-landmark-label' ); + landmarkLabels.forEach( ( label ) => { + label.remove(); + } ); + + // Remove landmark highlight classes + const landmarkHighlights = document.querySelectorAll( '.edac-landmark-highlight' ); + landmarkHighlights.forEach( ( element ) => { + element.classList.remove( 'edac-landmark-highlight' ); + element.removeAttribute( 'data-edac-landmark-label-id' ); + } ); + } } // Some systems (Cloudflare Rocket Loader) defers scripts for performance but that can diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js index 936149783..0f9ad2b2c 100644 --- a/src/pageScanner/index.js +++ b/src/pageScanner/index.js @@ -72,20 +72,91 @@ function getElementSelector( element ) { if ( ! element ) { return null; } + + // Use ID if available (most reliable) if ( element.id ) { return `#${ element.id }`; } + + // For landmark elements, try to use semantic selectors first + const tagName = element.tagName.toLowerCase(); + + // For main element, use tag selector if it's unique + if ( tagName === 'main' ) { + const mainElements = document.querySelectorAll( 'main' ); + if ( mainElements.length === 1 ) { + return 'main'; + } + } + + // For header/footer, check if they're direct children of body + if ( ( tagName === 'header' || tagName === 'footer' ) && element.parentElement === document.body ) { + return tagName; + } + + // For nav elements, try role-based selector first + if ( tagName === 'nav' || element.getAttribute( 'role' ) === 'navigation' ) { + const navElements = document.querySelectorAll( 'nav, [role="navigation"]' ); + if ( navElements.length === 1 ) { + return tagName === 'nav' ? 'nav' : '[role="navigation"]'; + } + // If multiple, try to use aria-label or other identifying attributes + if ( element.hasAttribute( 'aria-label' ) ) { + const ariaLabel = element.getAttribute( 'aria-label' ); + return `${ tagName === 'nav' ? 'nav' : '[role="navigation"]' }[aria-label="${ ariaLabel }"]`; + } + } + + // For other landmark roles, use role selector if unique + const role = element.getAttribute( 'role' ); + if ( role && LANDMARK_ROLES.includes( role ) ) { + const roleElements = document.querySelectorAll( `[role="${ role }"]` ); + if ( roleElements.length === 1 ) { + return `[role="${ role }"]`; + } + // If multiple, try to use aria-label + if ( element.hasAttribute( 'aria-label' ) ) { + const ariaLabel = element.getAttribute( 'aria-label' ); + return `[role="${ role }"][aria-label="${ ariaLabel }"]`; + } + } + + // Fallback to path-based selector (simplified) const path = []; - while ( element && element.nodeType === Node.ELEMENT_NODE && element !== document.body ) { - let selector = element.nodeName.toLowerCase(); - if ( element.className ) { - const classes = element.className.trim().split( /\s+/ ).join( '.' ); - selector += `.${ classes }`; + let current = element; + while ( current && current.nodeType === Node.ELEMENT_NODE && current !== document.body ) { + let selector = current.nodeName.toLowerCase(); + + // Add ID if available + if ( current.id ) { + selector = `#${ current.id }`; + path.unshift( selector ); + break; // Stop here since ID is unique + } + + // Add stable classes (avoid dynamic/generated classes) + if ( current.className ) { + const classes = current.className.trim().split( /\s+/ ) + .filter( ( cls ) => ! cls.match( /^(wp-|js-|css-|generated-|dynamic-)/ ) ) // Filter out common dynamic classes + .slice( 0, 2 ); // Limit to first 2 classes for stability + if ( classes.length > 0 ) { + selector += `.${ classes.join( '.' ) }`; + } } - const siblingIndex = Array.from( element.parentNode.children ).indexOf( element ) + 1; - selector += `:nth-child(${ siblingIndex })`; + + // Only add nth-child as last resort and only if element has no other identifying features + if ( ! current.id && ! current.className ) { + const siblingIndex = Array.from( current.parentNode.children ).indexOf( current ) + 1; + selector += `:nth-child(${ siblingIndex })`; + } + path.unshift( selector ); - element = element.parentElement; + current = current.parentElement; + + // Limit path depth to avoid overly complex selectors + if ( path.length >= 4 ) { + break; + } } return path.length ? path.join( ' > ' ) : null; } From 4c0207229138bd6b3b5e1efa2494f3217bac9afd Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 22:25:17 -0400 Subject: [PATCH 06/14] updated: to prevent variable hoisting --- src/frontendHighlighterApp/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index d8f404e05..9fbd3f4e7 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -1049,17 +1049,19 @@ class AccessibilityCheckerHighlight { return 'Complementary'; case 'footer': return 'Footer'; - case 'section': + case 'section': { // Check if section has accessible name const hasAccessibleName = element.getAttribute( 'aria-label' ) || element.getAttribute( 'aria-labelledby' ) || element.querySelector( 'h1, h2, h3, h4, h5, h6' ); return hasAccessibleName ? 'Region' : 'Section'; - case 'form': + } + case 'form': { // Check if form has accessible name const formHasAccessibleName = element.getAttribute( 'aria-label' ) || element.getAttribute( 'aria-labelledby' ); return formHasAccessibleName ? 'Form' : 'Form (unlabeled)'; + } default: return 'Landmark'; } From f21e8f337ef7b4f0dce7a56a8f6b17fbcfe1566e Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 22:29:59 -0400 Subject: [PATCH 07/14] added: escape CSS class names to prevent injection issues --- src/pageScanner/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js index 0f9ad2b2c..5d306305b 100644 --- a/src/pageScanner/index.js +++ b/src/pageScanner/index.js @@ -137,6 +137,7 @@ function getElementSelector( element ) { // Add stable classes (avoid dynamic/generated classes) if ( current.className ) { const classes = current.className.trim().split( /\s+/ ) + .map( ( cls ) => CSS.escape( cls ) ) .filter( ( cls ) => ! cls.match( /^(wp-|js-|css-|generated-|dynamic-)/ ) ) // Filter out common dynamic classes .slice( 0, 2 ); // Limit to first 2 classes for stability if ( classes.length > 0 ) { From 80c0643685908a14730a4e8b16ca868f0a9b84b3 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 22:41:54 -0400 Subject: [PATCH 08/14] added: adjust scrolling behavior to include a 75px offset when navigating to landmarks --- src/frontendHighlighterApp/index.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index 9fbd3f4e7..2dd564ff8 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -994,8 +994,13 @@ class AccessibilityCheckerHighlight { landmarkElement.classList.add( 'edac-highlight-element-selected-min-height' ); } - // Scroll to the landmark - landmarkElement.scrollIntoView( { block: 'center', behavior: 'smooth' } ); + // Scroll to the landmark with 20px offset from start + const elementRect = landmarkElement.getBoundingClientRect(); + const elementTop = elementRect.top + window.scrollY - 75; + window.scrollTo( { + top: elementTop, + behavior: 'smooth', + } ); } else { // Landmark element not found - silently fail From 2f2ada3f393139aeb86b2c5f5df83d5ec67bce00 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 22:53:45 -0400 Subject: [PATCH 09/14] added: set max-width and box-sizing for selected highlight elements --- src/frontendHighlighterApp/sass/app.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontendHighlighterApp/sass/app.scss b/src/frontendHighlighterApp/sass/app.scss index 4c91a7ac2..b840a9e7e 100644 --- a/src/frontendHighlighterApp/sass/app.scss +++ b/src/frontendHighlighterApp/sass/app.scss @@ -34,6 +34,8 @@ body { outline: dashed 4px transparent !important; outline-offset: 5px !important; outline-color: magenta !important; + max-width: calc(100vw - 30px) !important; + box-sizing: border-box !important; &-min-width { min-width: 25px !important; From 065b06476d0e5e1aea592fe8ff62963bbc5b0667 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 23:00:24 -0400 Subject: [PATCH 10/14] updated: add nullable type hints for landmark and landmark_selector parameters in insert method --- admin/class-insert-rule-data.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/class-insert-rule-data.php b/admin/class-insert-rule-data.php index 2e5ba15dd..51901827a 100644 --- a/admin/class-insert-rule-data.php +++ b/admin/class-insert-rule-data.php @@ -31,7 +31,7 @@ class Insert_Rule_Data { * @return void|int|\WP_Error The ID of the inserted record, void if no * record was inserted or a WP_Error if the insert failed. */ - public function insert( object $post, string $rule, string $ruletype, string $rule_obj, $landmark = null, $landmark_selector = null ) { + public function insert( object $post, string $rule, string $ruletype, string $rule_obj, ?string $landmark = null, ?string $landmark_selector = null ) { if ( ! isset( $post->ID, $post->post_type ) || empty( $rule ) From de8ca078af9d190ba3cf4d12fd89827734327898 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 23:03:11 -0400 Subject: [PATCH 11/14] updated: use null coalescing operator for landmark and landmark_selector in violation handling --- includes/classes/class-rest-api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index 1d5247da3..ea6fa2ce0 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -380,8 +380,8 @@ public function set_post_scan_results( $request ) { */ do_action( 'edac_before_rule', $post_id, $actual_rule_id, 'js' ); - $landmark = isset( $violation['landmark'] ) ? $violation['landmark'] : null; - $landmark_selector = isset( $violation['landmarkSelector'] ) ? $violation['landmarkSelector'] : null; + $landmark = $violation['landmark'] ?? null; + $landmark_selector = $violation['landmarkSelector'] ?? null; ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html, $landmark, $landmark_selector ); From 0510debdd928b442b8eb0e42e1a21d933fa51e5e Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 2 Jul 2025 23:20:28 -0400 Subject: [PATCH 12/14] updated: capitalize landmark names in aria labels and links for improved accessibility --- admin/class-ajax.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/admin/class-ajax.php b/admin/class-ajax.php index fada65a48..69b66a609 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -481,13 +481,11 @@ function ( $a, $b ) { ); // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main"). - $landmark_aria_label = sprintf( __( 'View %s landmark on website, opens a new window', 'accessibility-checker' ), $landmark ); + $landmark_aria_label = sprintf( __( 'View %s landmark on website, opens a new window', 'accessibility-checker' ), ucwords( $landmark ) ); // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main"). - $html .= '' . $landmark . ''; + $html .= '' . ucwords( $landmark ) . ''; } elseif ( $landmark ) { - $html .= $landmark; - } else { - $html .= ''; + $html .= ucwords( $landmark ); } $html .= '
'; From fed931219eb0ac5e2d9b360e28cffd3d35e79356 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Thu, 3 Jul 2025 00:04:19 -0400 Subject: [PATCH 13/14] updated: adjust z-index value for highlight tooltip to prevent overlap issues --- src/frontendHighlighterApp/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index 2dd564ff8..50cabdc07 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -966,7 +966,7 @@ class AccessibilityCheckerHighlight { font-size: 12px; font-weight: bold; border-radius: 3px; - z-index: 999999; + z-index: 99998; pointer-events: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1; From 205a64c7a2e2231250393221b90c7d6cb42f5c7e Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Thu, 3 Jul 2025 13:09:36 -0400 Subject: [PATCH 14/14] updated: revert database version to 1.0.4 in accessibility checker plugin --- accessibility-checker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/accessibility-checker.php b/accessibility-checker.php index fa1ef0528..cc7005a13 100755 --- a/accessibility-checker.php +++ b/accessibility-checker.php @@ -40,7 +40,7 @@ // Current database version. if ( ! defined( 'EDAC_DB_VERSION' ) ) { - define( 'EDAC_DB_VERSION', '1.0.5' ); + define( 'EDAC_DB_VERSION', '1.0.4' ); } // Plugin Folder Path.