diff --git a/demo/broken.html b/demo/broken.html index d39825b..ed1fc70 100644 --- a/demo/broken.html +++ b/demo/broken.html @@ -4,7 +4,7 @@ Broken Page -

Welcome to a broken page

+

Welcome to a broken page

@@ -15,6 +15,21 @@ + + Keyboard test + + +
This is a duplicate main-content ID div!
+ + +

This text has extremely low contrast ratio!

+ + +
WAI-ARIA elements
+ + + +

This page has multiple accessibility issues.

diff --git a/src/Rules/AriaRole.php b/src/Rules/AriaRole.php new file mode 100644 index 0000000..40b2110 --- /dev/null +++ b/src/Rules/AriaRole.php @@ -0,0 +1,76 @@ +query('//*[@role]'); + foreach ($roleElements as $el) { + if (!$el instanceof \DOMElement) { + continue; + } + $role = trim($el->getAttribute('role')); + if ($role === '') { + continue; + } + $roles = array_filter(explode(' ', $role)); + foreach ($roles as $r) { + if (!in_array(strtolower($r), self::$validRoles, true)) { + $violations[] = $this->createViolation( + "Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.", + $el + ); + } + } + } + + // 2. Check aria-controls Targets Existence + $controlsElements = $xpath->query('//*[@aria-controls]'); + foreach ($controlsElements as $el) { + if (!$el instanceof \DOMElement) { + continue; + } + $targetsStr = trim($el->getAttribute('aria-controls')); + if ($targetsStr === '') { + continue; + } + $targets = array_filter(explode(' ', $targetsStr)); + foreach ($targets as $targetId) { + $targetQuery = $xpath->query('//*[@id="' . $targetId . '"]'); + if ($targetQuery->length === 0) { + $violations[] = $this->createViolation( + "Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.", + $el + ); + } + } + } + + return $violations; + } + + public function getStandard(): WCAGStandard { return WCAGStandard::A; } + public function getSeverity(): Severity { return Severity::ERROR; } +} diff --git a/src/Rules/ColorContrast.php b/src/Rules/ColorContrast.php new file mode 100644 index 0000000..008b95b --- /dev/null +++ b/src/Rules/ColorContrast.php @@ -0,0 +1,124 @@ +hasAttribute('style')) { + return null; + } + + $style = $element->getAttribute('style'); + + $color = $this->parseColorValue($style, 'color'); + $bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background'); + + if ($color === null || $bg === null) { + return null; + } + + $cRGB = $this->hexToRgb($color); + $bgRGB = $this->hexToRgb($bg); + + if ($cRGB === null || $bgRGB === null) { + return null; + } + + $cLum = $this->getLuminance($cRGB[0], $cRGB[1], $cRGB[2]); + $bgLum = $this->getLuminance($bgRGB[0], $bgRGB[1], $bgRGB[2]); + + $ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05); + + $isLarge = false; + if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) { + $val = (float)$matches[1]; + $unit = strtolower($matches[2]); + if (($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5)) { + $isLarge = true; + } + } + if (preg_match('/font-weight\s*:\s*(bold|[7-9]00)/i', $style)) { + if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) { + $val = (float)$matches[1]; + $unit = strtolower($matches[2]); + if (($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2)) { + $isLarge = true; + } + } + } + + $minRatio = $isLarge ? 3.0 : 4.5; + + if ($ratio < $minRatio) { + $formattedRatio = round($ratio, 2); + return $this->createViolation( + $element, + "Insufficient contrast ratio of {$formattedRatio}:1. Minimum required is {$minRatio}:1.", + "Adjust colors to increase contrast. For example, text: {$color}, background: {$bg}." + ); + } + + return null; + } + + private function parseColorValue(string $style, string $property): ?string + { + if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) { + $value = trim($matches[1]); + if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) { + return $value; + } + $colors = [ + 'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000', + 'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00', + 'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0', + ]; + if (isset($colors[strtolower($value)])) { + return $colors[strtolower($value)]; + } + } + return null; + } + + private function hexToRgb(string $hex): ?array + { + $hex = ltrim($hex, '#'); + if (strlen($hex) === 3) { + $r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1)); + $g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1)); + $b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1)); + return [$r, $g, $b]; + } elseif (strlen($hex) === 6) { + $r = hexdec(substr($hex, 0, 2)); + $g = hexdec(substr($hex, 2, 2)); + $b = hexdec(substr($hex, 4, 2)); + return [$r, $g, $b]; + } + return null; + } + + private function getLuminance(float $r, float $g, float $b): float + { + $rs = $r / 255.0; + $gs = $g / 255.0; + $bs = $b / 255.0; + + $rVal = ($rs <= 0.03928) ? $rs / 12.92 : pow(($rs + 0.055) / 1.055, 2.4); + $gVal = ($gs <= 0.03928) ? $gs / 12.92 : pow(($gs + 0.055) / 1.055, 2.4); + $bVal = ($bs <= 0.03928) ? $bs / 12.92 : pow(($bs + 0.055) / 1.055, 2.4); + + return 0.2126 * $rVal + 0.7152 * $gVal + 0.0722 * $bVal; + } +} diff --git a/src/Rules/DuplicateId.php b/src/Rules/DuplicateId.php new file mode 100644 index 0000000..6c834f3 --- /dev/null +++ b/src/Rules/DuplicateId.php @@ -0,0 +1,48 @@ +query('//*[@id]'); + + $ids = []; + foreach ($elements as $el) { + if (!$el instanceof \DOMElement) { + continue; + } + $id = $el->getAttribute('id'); + if ($id === '') { + continue; + } + if (!isset($ids[$id])) { + $ids[$id] = []; + } + $ids[$id][] = $el; + } + + foreach ($ids as $id => $elementsWithId) { + if (count($elementsWithId) > 1) { + foreach ($elementsWithId as $el) { + $violations[] = $this->createViolation( + "Duplicate ID \"{$id}\" found. Element ID attribute must be unique across the document.", + $el + ); + } + } + } + + return $violations; + } + + public function getStandard(): WCAGStandard { return WCAGStandard::A; } + public function getSeverity(): Severity { return Severity::ERROR; } +} diff --git a/src/Rules/IframeTitle.php b/src/Rules/IframeTitle.php new file mode 100644 index 0000000..548d24c --- /dev/null +++ b/src/Rules/IframeTitle.php @@ -0,0 +1,33 @@ +tagName) !== 'iframe') { + return null; + } + + if (!$element->hasAttribute('title') || trim($element->getAttribute('title')) === '') { + return $this->createViolation( + $element, + "Iframe element is missing a descriptive title attribute.", + "Add a descriptive title attribute, e.g., title=\"Embedded Map\" or title=\"Video Player\"." + ); + } + + return null; + } +} diff --git a/src/Rules/StandardRuleSet.php b/src/Rules/StandardRuleSet.php index 481e674..f663c68 100644 --- a/src/Rules/StandardRuleSet.php +++ b/src/Rules/StandardRuleSet.php @@ -18,6 +18,11 @@ class StandardRuleSet new FieldsetLegend(), new HeadingOrder(), new MetaViewport(), + new TabindexOrder(), + new ColorContrast(), + new DuplicateId(), + new AriaRole(), + new IframeTitle(), ]; } } diff --git a/src/Rules/TabindexOrder.php b/src/Rules/TabindexOrder.php new file mode 100644 index 0000000..ff1142c --- /dev/null +++ b/src/Rules/TabindexOrder.php @@ -0,0 +1,34 @@ +hasAttribute('tabindex')) { + $tabindex = $element->getAttribute('tabindex'); + if (is_numeric($tabindex)) { + $val = (int)$tabindex; + if ($val > 0) { + return $this->createViolation( + $element, + "Avoid positive tabindex values (tabindex=\"{$val}\") to preserve the natural tab order.", + "Remove the positive tabindex or change it to \"0\" or \"-1\"." + ); + } + } + } + return null; + } +}