Skip to content

Commit 909935d

Browse files
committed
feat(a11y): add error annotations for various accessibility issues in HTML fixtures
1 parent 781f51a commit 909935d

103 files changed

Lines changed: 324 additions & 126 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/Rules/AbstractA11yRule.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ abstract class AbstractA11yRule extends AbstractRule implements EvaluatableRuleI
1616

1717
private const KIND_CACHE_MAX = 500;
1818

19+
/**
20+
* Maximum number of rule-file keys in the $emitted map. When exceeded the
21+
* map is reset to prevent unbounded memory growth during long-running
22+
* linting sessions (watch-mode, large CI pipelines, etc.).
23+
*/
24+
private const EMITTED_MAX = 2000;
25+
1926
/** Cached decision for the currently-processed file when rules are reused */
2027
private ?bool $skipThisFile = null;
2128

@@ -139,6 +146,10 @@ private function createEmitter(Tokens $tokens): callable
139146

140147
$ruleFileKey = static::class.'::'.$hash;
141148
if (!isset($this->emitted[$ruleFileKey])) {
149+
if (count($this->emitted) >= self::EMITTED_MAX) {
150+
$this->emitted = [];
151+
}
152+
142153
$this->emitted[$ruleFileKey] = [];
143154
}
144155

src/Rules/Anchor/AnchorAccessibleNameRule.php

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,7 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
2828
return;
2929
}
3030

31-
$full = $this->collectTag($tokenIndex, $tokens, 200);
32-
if (!str_contains($full, '>')) {
33-
$collected = $full;
34-
$i = $tokenIndex + 1;
35-
$limit = $tokenIndex + 200;
36-
while ($i <= $limit && $tokens->has($i) && !str_contains($collected, '>')) {
37-
$collected .= $tokens->get($i)->getValue();
38-
++$i;
39-
}
40-
41-
$full = $collected;
42-
}
31+
$full = $this->collectUntil($tokenIndex, $tokens, '</a>', 200);
4332

4433
if (!preg_match('/<\s*a\b([^>]*)>(.*?)<\s*\/\s*a\s*>/is', $full, $m)) {
4534
return;

src/Rules/Aria/AriaAllowedAttrRule.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
178178
return;
179179
}
180180

181+
$idx = 0;
182+
181183
if (preg_match_all('/<([a-z0-9]+)([^>]*)\srole\s*=\s*(?:"|\')([^"\']+)(?:"|\')([^>]*)>/i', $full, $matches, PREG_SET_ORDER)) {
182184
foreach ($matches as $m) {
183185
$role = strtolower($m[3]);
@@ -192,10 +194,10 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
192194
if (preg_match('/\baria-([a-z0-9-]+)/i', $ariaRaw, $an)) {
193195
$name = strtolower($an[1]);
194196
if (!in_array('aria-'.$name, $this->allowed[$role], true)) {
197+
++$idx;
195198
$fakeToken = $tokens->get(0);
196-
$emit(sprintf('Attribute aria-%s is not allowed on role %s.', $name, $role), $fakeToken, 'AriaAllowed.Invalid');
197-
198-
return;
199+
$id = 1 === $idx ? 'AriaAllowed.Invalid' : sprintf('AriaAllowed.Invalid#%d', $idx);
200+
$emit(sprintf('Attribute aria-%s is not allowed on role %s.', $name, $role), $fakeToken, $id);
199201
}
200202
}
201203
}

src/Rules/Aria/AriaHiddenBodyRule.php

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,6 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
1818

1919
$full = $this->getFullContent($tokens);
2020

21-
// Only apply to full pages
22-
if (!str_contains(strtoupper($full), '<!DOCTYPE') && !str_contains($full, '<body')) {
23-
return;
24-
}
25-
2621
if (preg_match('/<body[^>]*aria-hidden\s*=\s*(?:"|\')true(?:"|\')/i', $full)) {
2722
$first = $tokens->get(0);
2823
$emit('Do not set aria-hidden="true" on the <body> element.', $first, 'AriaHiddenBody.HiddenOnBody');

src/Rules/Aria/AriaHiddenFocusRule.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
2929
$tagName = strtolower($set[1]);
3030
$attrs = $set[2];
3131
if (preg_match('/aria-hidden\s*=\s*(?:"|\')true(?:"|\')/i', $attrs)) {
32-
// Focusable detection: element tag or attributes indicating focusability
32+
// Focusable detection: element tag or attributes indicating focusability.
33+
// tabindex="-1" removes an element from tab order, so it is NOT considered
34+
// tab-focusable. Only tabindex >= 0 creates a keyboard-focusable element.
3335
$focusableTags = ['button', 'input', 'select', 'textarea', 'a'];
36+
$hasPositiveTabindex = (bool) preg_match('/tabindex\s*=\s*(?:"|\')?\s*(\d+)/i', $attrs, $ti)
37+
&& (int) $ti[1] >= 0;
3438
$isFocusable = in_array($tagName, $focusableTags, true)
35-
|| preg_match('/href\s*=|tabindex\s*=/i', $attrs);
39+
|| preg_match('/href\s*=/i', $attrs)
40+
|| $hasPositiveTabindex;
3641

3742
if ($isFocusable) {
3843
// add a generic token (first text token) for location

src/Rules/Aria/AriaLabelRule.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,16 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
3030

3131
$tag = $this->collectUntil($tokenIndex, $tokens, '>');
3232

33-
// If aria-label present and non-empty - OK
33+
// aria-label present and non-empty - OK
3434
if (preg_match('/aria-label\s*=\s*(?:"|\')([^"\']*)(?:"|\')/i', $tag, $m) && '' !== trim($m[1])) {
3535
return;
3636
}
3737

38+
// aria-labelledby present and non-empty - also acceptable per WCAG
39+
if (preg_match('/aria-labelledby\s*=\s*(?:"|\')([^"\']*)(?:"|\')/i', $tag, $m) && '' !== trim($m[1])) {
40+
return;
41+
}
42+
3843
$emit(
3944
'Landmark elements should have a non-empty aria-label.',
4045
$token,

src/Rules/Aria/AriaRequiredAttrRule.php

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,18 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
2222
return;
2323
}
2424

25-
// Extended mapping of some roles to required attributes (non-exhaustive)
25+
// Extended mapping of some roles to required attributes (non-exhaustive).
26+
// Each entry is a list of OR-groups: the role is valid when at least one attribute
27+
// in each group is present. A group with a single entry means that one attribute is mandatory.
28+
// A group with multiple entries means any one of them is sufficient.
2629
$requiredMap = [
27-
'img' => ['alt'],
28-
'link' => ['href'],
29-
'textbox' => ['aria-label', 'aria-labelledby'],
30-
'combobox' => ['aria-controls'],
30+
'img' => [['alt']],
31+
'link' => [['href']],
32+
'textbox' => [['aria-label', 'aria-labelledby']], // either is acceptable
33+
'combobox' => [['aria-controls']],
3134
'button' => [],
32-
'checkbox' => ['aria-checked'],
33-
'radio' => ['aria-checked'],
35+
'checkbox' => [['aria-checked']],
36+
'radio' => [['aria-checked']],
3437
];
3538

3639
if (preg_match_all('/<([a-z0-9]+)([^>]*)>/i', $full, $tags, PREG_SET_ORDER)) {
@@ -39,12 +42,23 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
3942
if (preg_match('/role\s*=\s*(?:"|\')([^"\']+)(?:"|\')/i', $attrs, $m)) {
4043
$role = strtolower($m[1]);
4144
if (isset($requiredMap[$role])) {
42-
foreach ($requiredMap[$role] as $attr) {
43-
if (!preg_match('/\b'.preg_quote($attr, '/').'\s*=\s*(?:"|\')/i', $attrs)) {
45+
foreach ($requiredMap[$role] as $group) {
46+
// The group is satisfied when at least one of its attributes is present
47+
$satisfied = false;
48+
foreach ($group as $attr) {
49+
if (preg_match('/\b'.preg_quote($attr, '/').'\s*=\s*(?:"|\')/i', $attrs)) {
50+
$satisfied = true;
51+
52+
break;
53+
}
54+
}
55+
56+
if (!$satisfied) {
4457
$tokenRef = $tokens->get(0);
45-
$emit(sprintf('Role "%s" requires attribute "%s".', $role, $attr), $tokenRef, 'AriaRequired.Missing');
58+
$missing = implode('" or "', $group);
59+
$emit(sprintf('Role "%s" requires attribute "%s".', $role, $missing), $tokenRef, 'AriaRequired.Missing');
4660

47-
// stop after first missing attribute found for test determinism
61+
// stop after first missing group found for test determinism
4862
return;
4963
}
5064
}

src/Rules/Forms/AbstractFormFieldLabelRule.php

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,29 @@ abstract protected function messageId(): string;
6565
* Allow subclasses to perform extra checks on the opening tag. Return true
6666
* when the opening tag already provides a label (eg. aria-labelledby).
6767
*
68-
* The base implementation accepts both aria-label and aria-labelledby.
69-
* Both attributes are valid for most form elements. Subclasses that need
70-
* to further restrict or extend this list can override the method.
68+
* The base implementation accepts both aria-label (non-empty) and aria-labelledby
69+
* (non-empty). Subclasses that need to further restrict or extend this list can
70+
* override the method.
7171
*/
7272
protected function openingProvidesLabel(string $opening): bool
7373
{
74-
if (preg_match('/\baria-labelledby\s*=\s*(?:"|\')/i', $opening)) {
75-
return true;
74+
// aria-labelledby with any non-empty value
75+
if (preg_match('/\baria-labelledby\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', $opening, $m)) {
76+
$value = '' !== $m[1] ? $m[1] : ($m[2] ?? '');
77+
if ('' !== trim($value)) {
78+
return true;
79+
}
7680
}
7781

78-
return (bool) preg_match('/\baria-label\s*=\s*(?:"|\')/i', $opening);
82+
// aria-label with a non-empty value
83+
if (preg_match('/\baria-label\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', $opening, $m)) {
84+
$value = '' !== $m[1] ? $m[1] : ($m[2] ?? '');
85+
if ('' !== trim($value)) {
86+
return true;
87+
}
88+
}
89+
90+
return false;
7991
}
8092

8193
/**

src/Rules/Forms/InputLabelRule.php

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,9 @@ protected function messageId(): string
2323

2424
protected function openingProvidesLabel(string $opening): bool
2525
{
26-
// aria-label or aria-labelledby on the input itself is acceptable
27-
if (preg_match('/\baria-label\s*=\s*(?:"|\')/i', $opening)) {
28-
return true;
29-
}
30-
31-
return (bool) preg_match('/\baria-labelledby\s*=\s*(?:"|\')/i', $opening);
26+
// Delegate to the base implementation which checks for non-empty values.
27+
// Both aria-label (non-empty) and aria-labelledby (non-empty) are acceptable.
28+
return parent::openingProvidesLabel($opening);
3229
}
3330

3431
protected function isHidden(string $opening): bool

src/Rules/Forms/InputTypeRule.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
final class InputTypeRule extends AbstractA11yRule
1111
{
1212
/**
13-
* Input types that require an autocomplete attribute for WCAG 1.3.5 (Identify Input Purpose).
13+
* Valid HTML input type values that carry personal data and therefore require an autocomplete
14+
* attribute (WCAG 1.3.5 — Identify Input Purpose).
15+
*
16+
* Note: values such as "name", "username", "new-password" are autocomplete *tokens*, not
17+
* valid type= values, so they must NOT appear here.
1418
*/
15-
private const AUTOCOMPLETE_REQUIRED_TYPES = ['email', 'tel', 'name', 'username', 'new-password', 'current-password'];
19+
private const AUTOCOMPLETE_REQUIRED_TYPES = ['email', 'tel'];
1620

1721
/**
1822
* Check inputs with personal-data types have an autocomplete attribute (WCAG 1.3.5).

0 commit comments

Comments
 (0)