*/ private static function understanding_slugs() { static $slugs = null; if ( null !== $slugs ) { return $slugs; } $slugs = array( '1.1.1' => 'non-text-content', '1.3.1' => 'info-and-relationships', '1.4.3' => 'contrast-minimum', '1.4.4' => 'resize-text', '1.4.11' => 'non-text-contrast', '2.1.1' => 'keyboard', '2.4.1' => 'bypass-blocks', '2.4.2' => 'page-titled', '2.4.4' => 'link-purpose-in-context', '2.4.6' => 'headings-and-labels', '2.4.7' => 'focus-visible', '2.4.11' => 'focus-not-obscured-minimum', '2.5.7' => 'dragging-movements', '2.5.8' => 'target-size-minimum', '3.1.1' => 'language-of-page', '4.1.1' => 'parsing', '4.1.2' => 'name-role-value', ); return $slugs; } /** * Keyword rules: [ wcag_id, lowercase keyword ('' = any type for that id), entry slug ]. * First match wins, so put the specific keywords before the catch-all of the same id. * * @return array> */ private static function keyword_rules() { static $rules = null; if ( null !== $rules ) { return $rules; } $rules = array( array( '1.1.1', 'alt', 'img-alt' ), array( '1.3.1', 'label', 'form-label' ), array( '1.3.1', 'heading', 'heading-skip' ), array( '1.4.3', 'contrast', 'contrast' ), array( '2.4.2', 'title', 'page-title' ), array( '2.4.4', 'non-descriptive', 'link-text' ), array( '2.4.4', 'empty', 'link-empty' ), array( '2.4.6', 'heading', 'heading-empty' ), array( '2.4.11', 'focus', 'focus-obscured' ), array( '2.5.7', 'drag', 'dragging' ), array( '2.5.8', 'target', 'target-size' ), array( '3.1.1', 'lang', 'html-lang' ), array( '4.1.1', 'duplicate', 'duplicate-id' ), array( '4.1.2', 'button', 'button-name' ), ); return $rules; } /** * Resolve the entry slug for an issue. Never throws, always returns a slug. * * @param string $wcag_id e.g. "1.1.1". * @param string $issue_type Free text, may contain quoted text or ids. * @return string */ public static function resolve_slug( $wcag_id, $issue_type ) { $wcag_id = trim( (string) $wcag_id ); $issue_type = strtolower( (string) $issue_type ); $generic = false; foreach ( self::keyword_rules() as $rule ) { if ( $rule[0] !== $wcag_id ) { continue; } if ( '' === $rule[1] || false !== strpos( $issue_type, $rule[1] ) ) { return $rule[2]; } $generic = true; } // Known criterion, unknown wording: criterion-level generic entry. if ( $generic || isset( self::understanding_slugs()[ $wcag_id ] ) ) { return 'criterion'; } return 'generic'; } /** * Help for one issue. * * @param string $wcag_id WCAG criterion id. * @param string $issue_type Issue type text. * @return array{slug:string,why:string,fix:string,bad:string,good:string,url:string} */ public static function get( $wcag_id, $issue_type = '' ) { $wcag_id = trim( (string) $wcag_id ); $slug = self::resolve_slug( $wcag_id, $issue_type ); $entries = self::entries(); $entry = isset( $entries[ $slug ] ) ? $entries[ $slug ] : $entries['generic']; $understanding = self::understanding_slugs(); if ( isset( $understanding[ $wcag_id ] ) ) { $url = 'https://www.w3.org/WAI/WCAG22/Understanding/' . $understanding[ $wcag_id ] . '.html'; } else { $url = 'https://www.w3.org/WAI/WCAG22/quickref/'; } return array( 'slug' => $slug, 'why' => $entry['why'], 'fix' => $entry['fix'], 'bad' => $entry['bad'], 'good' => $entry['good'], 'url' => $url, ); } /** * Entry texts. Built on demand so translations load after init. * * @return array> */ private static function entries() { static $entries = null; if ( null !== $entries ) { return $entries; } $entries = array( 'img-alt' => array( 'why' => __( 'People who use screen readers cannot see an image without a text alternative, so they miss what it shows.', 'accessiq' ), 'fix' => __( 'Add an alt attribute that says what the image shows or does. Use alt="" when the image is purely decorative.', 'accessiq' ), 'bad' => '', 'good' => 'The support team at their desks', ), 'form-label' => array( 'why' => __( 'Without a label, screen reader users do not know what to type in a field, and people using voice control cannot name it.', 'accessiq' ), 'fix' => __( 'Connect a visible label to the field with matching for and id attributes. Use aria-label only when no visible label fits.', 'accessiq' ), 'bad' => '', 'good' => '' . "\n" . '', ), 'heading-skip' => array( 'why' => __( 'Screen reader users move through a page by headings. A skipped level makes the page outline confusing.', 'accessiq' ), 'fix' => __( 'Use heading levels in order (h2, then h3). Pick the level for the structure, not for the size; style size with CSS.', 'accessiq' ), 'bad' => '

Services

' . "\n" . '

Pricing

', 'good' => '

Services

' . "\n" . '

Pricing

', ), 'heading-empty' => array( 'why' => __( 'An empty heading is announced as a heading with no name, which adds noise for screen reader users.', 'accessiq' ), 'fix' => __( 'Add meaningful text to the heading, or remove the heading tag if it is only used for spacing.', 'accessiq' ), 'bad' => '

', 'good' => '

Opening hours

', ), 'contrast' => array( 'why' => __( 'Low contrast text is hard to read for people with low vision, colour vision differences, or a glare on the screen.', 'accessiq' ), 'fix' => __( 'Make the text colour darker or the background lighter until the ratio is at least 4.5:1 (3:1 for large text). This check is an estimate from computed styles; confirm with a contrast tool.', 'accessiq' ), 'bad' => '

Sale ends soon

', 'good' => '

Sale ends soon

', ), 'page-title' => array( 'why' => __( 'The page title is the first thing a screen reader announces and the label in browser tabs and history.', 'accessiq' ), 'fix' => __( 'Add a short, unique title element in the head that describes the page.', 'accessiq' ), 'bad' => '', 'good' => 'Contact us - Example Co', ), 'link-text' => array( 'why' => __( 'Links like "click here" make no sense when a screen reader lists all links on a page.', 'accessiq' ), 'fix' => __( 'Write link text that says where the link goes or what it does.', 'accessiq' ), 'bad' => 'click here', 'good' => 'Read the setup guide', ), 'link-empty' => array( 'why' => __( 'A link with no text is announced as just "link", so people cannot tell where it goes.', 'accessiq' ), 'fix' => __( 'Add visible link text, or an aria-label when the link only contains an icon.', 'accessiq' ), 'bad' => '', 'good' => '', ), 'html-lang' => array( 'why' => __( 'Screen readers use the page language to pick the right pronunciation. Without it, text can be read with the wrong accent.', 'accessiq' ), 'fix' => __( 'Add a lang attribute with the correct language code to the html element.', 'accessiq' ), 'bad' => '', 'good' => '', ), 'duplicate-id' => array( 'why' => __( 'Duplicate ids can break labels, skip links and ARIA references, so assistive technology may point at the wrong element.', 'accessiq' ), 'fix' => __( 'Give every id on the page a unique value. Note that 4.1.1 Parsing is obsolete in WCAG 2.2; it is kept here only for WCAG 2.1 scans, and duplicate ids still matter when labels or ARIA refer to them.', 'accessiq' ), 'bad' => '' . "\n" . '', 'good' => '' . "\n" . '', ), 'button-name' => array( 'why' => __( 'A button with no name is announced as just "button", so people cannot tell what it does.', 'accessiq' ), 'fix' => __( 'Put text inside the button, or add an aria-label when it only shows an icon.', 'accessiq' ), 'bad' => '', 'good' => '', ), 'focus-obscured' => array( 'why' => __( 'Sticky or fixed elements can cover the control that has keyboard focus, so keyboard users cannot see where they are.', 'accessiq' ), 'fix' => __( 'Test by tabbing through the page. Add scroll-padding-top equal to the sticky header height so focused items stay visible. This check only flags likely cases.', 'accessiq' ), 'bad' => 'header { position: fixed; }', 'good' => 'html { scroll-padding-top: 5rem; }' . "\n" . 'header { position: fixed; }', ), 'dragging' => array( 'why' => __( 'Dragging is hard or impossible for people with tremors, limited dexterity, or who use a switch or voice control.', 'accessiq' ), 'fix' => __( 'Offer a second way to do the same thing with single clicks or taps, such as move up/down buttons. This check only flags likely cases.', 'accessiq' ), 'bad' => '
  • Item
  • ', 'good' => '
  • Item
  • ', ), 'target-size' => array( 'why' => __( 'Very small buttons and links are hard to hit for people with tremors or limited dexterity, and on touch screens.', 'accessiq' ), 'fix' => __( 'Make interactive targets at least 24 by 24 CSS pixels, or add padding or spacing around them.', 'accessiq' ), 'bad' => 'x', 'good' => 'x', ), 'criterion' => array( 'why' => __( 'This check finds a likely problem against a WCAG criterion that can affect people who use assistive technology.', 'accessiq' ), 'fix' => __( 'Read the WCAG guidance for this criterion, fix the element shown, then scan again. Automated checks cannot judge everything, so review the result by hand.', 'accessiq' ), 'bad' => '', 'good' => '', ), 'generic' => array( 'why' => __( 'This check finds a likely accessibility problem that can affect people who use assistive technology.', 'accessiq' ), 'fix' => __( 'Review the element shown and the suggestion above, fix it, then scan again. Automated checks cannot judge everything, so review the result by hand.', 'accessiq' ), 'bad' => '', 'good' => '', ), ); return $entries; } /** * Rank active (non-dismissed) issues already loaded for a scan. No queries. * Order: errors before warnings, higher count, lower WCAG level, then label. * admin.js (renderFixFirst) mirrors this order when an issue is ignored or restored. * * @param array $issues Rows from AccessIQ_Scanner::get_scan_issues(). * @param int $limit Max groups. * @return array> */ public static function fix_first( array $issues, $limit = 5 ) { $level_rank = array( 'A' => 1, 'AA' => 2, 'AAA' => 3 ); $groups = array(); foreach ( $issues as $issue ) { if ( ! empty( $issue->dismissed_at ) ) { continue; } $severity = ( 'warning' === $issue->severity ) ? 'warning' : 'error'; $label = self::normalise_type( $issue->issue_type ); $key = $severity . '|' . $issue->wcag_id . '|' . strtolower( $label ); if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'label' => $label, 'wcag_id' => $issue->wcag_id, 'severity' => $severity, 'level' => $issue->level, 'count' => 0, 'first_id' => (int) $issue->id, ); } ++$groups[ $key ]['count']; } uasort( $groups, function ( $a, $b ) use ( $level_rank ) { if ( $a['severity'] !== $b['severity'] ) { return 'error' === $a['severity'] ? -1 : 1; } if ( $a['count'] !== $b['count'] ) { return $b['count'] - $a['count']; } $la = isset( $level_rank[ $a['level'] ] ) ? $level_rank[ $a['level'] ] : 1; $lb = isset( $level_rank[ $b['level'] ] ) ? $level_rank[ $b['level'] ] : 1; if ( $la !== $lb ) { return $la - $lb; } return strcmp( strtolower( $a['label'] ), strtolower( $b['label'] ) ); } ); return array_slice( array_values( $groups ), 0, max( 1, (int) $limit ) ); } /** * Normalise an issue type for grouping and display: drops the dynamic part after a colon * ("Duplicate ID: "x" appears 3 times"), quoted text and numbers. Case is kept; lowercase it for a key. * * @param string $type Issue type. * @return string */ public static function normalise_type( $type ) { $type = (string) $type; $type = preg_replace( '/\s*:.*$/s', '', $type ); $type = preg_replace( '/"[^"]*"|\'[^\']*\'/', '', $type ); $type = preg_replace( '/\d+/', '', $type ); return trim( preg_replace( '/\s+/', ' ', $type ) ); } }