diff --git a/demo/broken.html b/demo/broken.html index ed1fc70..a08732c 100644 --- a/demo/broken.html +++ b/demo/broken.html @@ -30,6 +30,25 @@ + + + + Click here + devamını oku + + + + + + logo of our company + profile photo of john doe + + + + +

This page has multiple accessibility issues.

diff --git a/src/Rules/AriaHiddenFocusable.php b/src/Rules/AriaHiddenFocusable.php new file mode 100644 index 0000000..086843b --- /dev/null +++ b/src/Rules/AriaHiddenFocusable.php @@ -0,0 +1,87 @@ +getAttribute('aria-hidden') !== 'true') { + return null; + } + + if ($this->isFocusable($element) || $this->hasFocusableDescendant($element)) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Remove aria-hidden="true" from the container or make its focusable elements non-focusable (e.g. by setting tabindex="-1" or disabled).' + ); + } + + return null; + } + + private function isFocusable(DOMElement $element): bool + { + $tagName = strtolower($element->tagName); + + if ($element->hasAttribute('tabindex')) { + $tabindex = (int)$element->getAttribute('tabindex'); + if ($tabindex < 0) { + return false; + } + return true; + } + + if ($tagName === 'a' && $element->hasAttribute('href')) { + return true; + } + + if (in_array($tagName, ['button', 'select', 'textarea'], true)) { + return !$element->hasAttribute('disabled'); + } + + if ($tagName === 'input') { + $type = strtolower($element->getAttribute('type')); + return $type !== 'hidden' && !$element->hasAttribute('disabled'); + } + + if ($tagName === 'iframe') { + return true; + } + + return false; + } + + private function hasFocusableDescendant(DOMElement $element): bool + { + $doc = $element->ownerDocument; + if ($doc === null) { + return false; + } + + $xpath = new \DOMXPath($doc); + $descendants = $xpath->query('.//*', $element); + if ($descendants === false) { + return false; + } + + foreach ($descendants as $descendant) { + if ($descendant instanceof DOMElement && $this->isFocusable($descendant)) { + return true; + } + } + + return false; + } +} diff --git a/src/Rules/ImageAltRedundant.php b/src/Rules/ImageAltRedundant.php new file mode 100644 index 0000000..8f39e3d --- /dev/null +++ b/src/Rules/ImageAltRedundant.php @@ -0,0 +1,52 @@ + */ + private array $redundantWords = [ + 'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu', + 'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of' + ]; + + public function check(DOMElement $element): ?Violation + { + if (strtolower($element->tagName) !== 'img') { + return null; + } + + if (!$element->hasAttribute('alt')) { + return null; + } + + $alt = trim($element->getAttribute('alt')); + $cleanAlt = strtolower(preg_replace('/\s+/', ' ', $alt) ?? ''); + + if ($cleanAlt === '') { + return null; + } + + foreach ($this->redundantWords as $word) { + if (str_starts_with($cleanAlt, $word) || str_contains($cleanAlt, ' ' . $word)) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Remove redundant phrases like "' . $word . '" from the alt text. Screen readers already announce elements as images.' + ); + } + } + + return null; + } +} diff --git a/src/Rules/LinkTextGeneric.php b/src/Rules/LinkTextGeneric.php new file mode 100644 index 0000000..3b13715 --- /dev/null +++ b/src/Rules/LinkTextGeneric.php @@ -0,0 +1,47 @@ + */ + private array $genericWords = [ + 'click here', 'click', 'read more', 'more', 'here', 'download', 'go', 'details', 'link', 'website', 'page', 'info', + 'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi' + ]; + + public function check(DOMElement $element): ?Violation + { + if (strtolower($element->tagName) !== 'a') { + return null; + } + + $text = trim($element->textContent); + $cleanText = strtolower(preg_replace('/\s+/', ' ', $text) ?? ''); + + if (in_array($cleanText, $this->genericWords, true)) { + // Check for descriptive attributes + if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) { + return null; + } + + return $this->createViolation( + $element, + $this->getDescription(), + 'Change the link text to describe its target, or add an aria-label attribute to provide context.' + ); + } + + return null; + } +} diff --git a/src/Rules/PlaceholderAsLabel.php b/src/Rules/PlaceholderAsLabel.php new file mode 100644 index 0000000..081170e --- /dev/null +++ b/src/Rules/PlaceholderAsLabel.php @@ -0,0 +1,68 @@ +tagName), $tags, true)) { + return null; + } + + // Skip hidden inputs, buttons, etc. + $type = strtolower($element->getAttribute('type')); + if ($type === 'hidden' || $type === 'submit' || $type === 'button' || $type === 'reset') { + return null; + } + + // If it doesn't have a placeholder, this rule doesn't apply (FormLabel handles general missing labels) + if (!$element->hasAttribute('placeholder')) { + return null; + } + + // Check if there is an aria-label or aria-labelledby + if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) { + return null; + } + + // Check for associated label element + if ($element->hasAttribute('id')) { + $id = $element->getAttribute('id'); + $doc = $element->ownerDocument; + if ($doc !== null) { + $xpath = new \DOMXPath($doc); + $labels = $xpath->query("//label[@for='$id']"); + if ($labels !== false && $labels->length > 0) { + return null; + } + } + } + + // Check if wrapped in label + $parent = $element->parentNode; + while ($parent) { + if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'label') { + return null; + } + $parent = $parent->parentNode; + } + + return $this->createViolation( + $element, + $this->getDescription(), + 'Add a