Skip to content

Commit 27b8049

Browse files
committed
feat(a11y): add AriaErrorMessageIdExistsRule and RadioGroupAccessibleNameRule with tests
1 parent 0279ad0 commit 27b8049

13 files changed

Lines changed: 270 additions & 3 deletions

README.md

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

composer.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Aria;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class AriaErrorMessageIdExistsRule extends AbstractA11yRule
12+
{
13+
public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
14+
{
15+
if ($this->shouldSkipByTokenIndex($tokenIndex)) {
16+
return;
17+
}
18+
19+
$full = $this->getFullContent($tokens);
20+
21+
$idCount = preg_match_all('/\bid\s*=\s*(?:"|\')([^"\']+)(?:"|\')/i', $full, $idMatches);
22+
$ids = [];
23+
if ($idCount > 0) {
24+
$ids = array_flip($idMatches[1]);
25+
}
26+
27+
if (!preg_match_all('/\baria-errormessage\s*=\s*(?:"([^"]+)"|\'([^\']+)\')/i', $full, $refs, PREG_SET_ORDER)) {
28+
return;
29+
}
30+
31+
foreach ($refs as $ref) {
32+
$refId = $this->firstMatch($ref, 1, 2);
33+
if ('' === $refId || isset($ids[$refId])) {
34+
continue;
35+
}
36+
37+
$pos = strpos($full, $ref[0]);
38+
$line = 1;
39+
if (false !== $pos) {
40+
$line += substr_count(substr($full, 0, $pos), "\n");
41+
}
42+
43+
$token = $tokens->get(0);
44+
$fakeToken = new Token(
45+
$token->getType(),
46+
$line,
47+
1,
48+
$token->getFilename(),
49+
$ref[0]
50+
);
51+
52+
$emit(sprintf('Referenced id "%s" in aria-errormessage does not exist in template.', $refId), $fakeToken, 'AriaErrorMessage.MissingId');
53+
54+
return;
55+
}
56+
}
57+
58+
protected function evaluateOncePerFile(): bool
59+
{
60+
return true;
61+
}
62+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Forms;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class RadioGroupAccessibleNameRule extends AbstractA11yRule
12+
{
13+
public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
14+
{
15+
if ($this->shouldSkipByTokenIndex($tokenIndex)) {
16+
return;
17+
}
18+
19+
$full = $this->getFullContent($tokens);
20+
21+
if (!str_contains($full, 'type="radio"') && !str_contains($full, "type='radio'")) {
22+
return;
23+
}
24+
25+
if (preg_match_all('/<fieldset\b[^>]*>(.*?)<\/fieldset>/is', $full, $fieldsets, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
26+
foreach ($fieldsets as $fieldset) {
27+
$fieldsetBlock = $fieldset[0][0];
28+
$offset = $fieldset[0][1];
29+
$content = $fieldset[1][0];
30+
31+
$radioCount = preg_match_all('/<input\b[^>]*\btype\s*=\s*(?:"radio"|\'radio\')[^>]*>/i', $content);
32+
if ($radioCount < 2) {
33+
continue;
34+
}
35+
36+
if (preg_match('/<legend\b[^>]*>\s*([^<]+?)\s*<\/legend>/i', $fieldsetBlock, $legendMatch) && '' !== trim($legendMatch[1])) {
37+
continue;
38+
}
39+
40+
$line = 1 + substr_count(substr($full, 0, $offset), "\n");
41+
$fakeToken = $this->fakeTokenForLine($tokens, $line, $fieldsetBlock);
42+
$emit('Fieldsets containing radio groups should provide a non-empty <legend>.', $fakeToken, 'RadioGroupAccessibleName.MissingLegend');
43+
44+
return;
45+
}
46+
}
47+
48+
if (!preg_match_all('/<(div|section|fieldset)\b[^>]*\brole\s*=\s*(?:"radiogroup"|\'radiogroup\')[^>]*>(.*?)<\/\1>/is', $full, $groups, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
49+
return;
50+
}
51+
52+
foreach ($groups as $group) {
53+
$groupBlock = $group[0][0];
54+
$offset = $group[0][1];
55+
$content = $group[2][0];
56+
57+
$radioCount = preg_match_all('/<input\b[^>]*\btype\s*=\s*(?:"radio"|\'radio\')[^>]*>/i', $content);
58+
if ($radioCount < 2) {
59+
continue;
60+
}
61+
62+
if ($this->hasNonEmptyReference($groupBlock, 'aria-labelledby') || $this->hasNonEmptyReference($groupBlock, 'aria-label')) {
63+
continue;
64+
}
65+
66+
$line = 1 + substr_count(substr($full, 0, $offset), "\n");
67+
$fakeToken = $this->fakeTokenForLine($tokens, $line, $groupBlock);
68+
$emit('Containers with role="radiogroup" should have an accessible name via aria-label or aria-labelledby.', $fakeToken, 'RadioGroupAccessibleName.MissingName');
69+
70+
return;
71+
}
72+
}
73+
74+
protected function evaluateOncePerFile(): bool
75+
{
76+
return true;
77+
}
78+
79+
private function hasNonEmptyReference(string $tag, string $attribute): bool
80+
{
81+
if (!preg_match('/\b'.preg_quote($attribute, '/').'\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', $tag, $match)) {
82+
return false;
83+
}
84+
85+
return '' !== trim($this->firstMatch($match, 1, 2));
86+
}
87+
88+
private function fakeTokenForLine(Tokens $tokens, int $line, string $value): Token
89+
{
90+
$token = $tokens->get(0);
91+
92+
return new Token(
93+
$token->getType(),
94+
$line,
95+
1,
96+
$token->getFilename(),
97+
$value
98+
);
99+
}
100+
}

src/Standard/StandardRuleSets.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use TwigA11y\Rules\Aria\AriaAllowedAttrRule;
1010
use TwigA11y\Rules\Aria\AriaControlsIdExistsRule;
1111
use TwigA11y\Rules\Aria\AriaDeprecatedRoleRule;
12+
use TwigA11y\Rules\Aria\AriaErrorMessageIdExistsRule;
1213
use TwigA11y\Rules\Aria\AriaHiddenBodyRule;
1314
use TwigA11y\Rules\Aria\AriaHiddenFocusRule;
1415
use TwigA11y\Rules\Aria\AriaLabelRule;
@@ -30,6 +31,7 @@
3031
use TwigA11y\Rules\Forms\InvalidFieldErrorMessageRule;
3132
use TwigA11y\Rules\Forms\LabelForTargetExistsRule;
3233
use TwigA11y\Rules\Forms\PlaceholderOnlyLabelRule;
34+
use TwigA11y\Rules\Forms\RadioGroupAccessibleNameRule;
3335
use TwigA11y\Rules\Forms\RadioGroupStructureRule;
3436
use TwigA11y\Rules\Forms\SelectLabelRule;
3537
use TwigA11y\Rules\Forms\TextareaLabelRule;
@@ -154,6 +156,7 @@ public static function strict(): array
154156
AriaRequiredParentRule::class,
155157
AriaReferencedIdExistsRule::class,
156158
AriaControlsIdExistsRule::class,
159+
AriaErrorMessageIdExistsRule::class,
157160
AriaAllowedAttrRule::class,
158161
AriaHiddenBodyRule::class,
159162
AutocompleteValidRule::class,
@@ -180,6 +183,7 @@ public static function strict(): array
180183
DialogAccessibleNameRule::class,
181184
LabelForTargetExistsRule::class,
182185
PlaceholderOnlyLabelRule::class,
186+
RadioGroupAccessibleNameRule::class,
183187
RadioGroupStructureRule::class,
184188
ColorContrastRule::class,
185189
FrameTitleRule::class,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Tests\Rules\Aria;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use TwigA11y\Rules\Aria\AriaErrorMessageIdExistsRule;
10+
use TwigCsFixer\Test\AbstractRuleTestCase;
11+
12+
/**
13+
* @internal
14+
*/
15+
#[CoversClass(AriaErrorMessageIdExistsRule::class)]
16+
final class AriaErrorMessageIdExistsRuleTest extends AbstractRuleTestCase
17+
{
18+
/** @param array<string, string> $expectedErrors */
19+
#[DataProvider('provideFixtures')]
20+
public function testRule(string $fixture, array $expectedErrors): void
21+
{
22+
$this->checkRule(new AriaErrorMessageIdExistsRule(), $expectedErrors, $fixture);
23+
}
24+
25+
/** @return iterable<string, array{0:string,1:array<string,string>}> */
26+
public static function provideFixtures(): iterable
27+
{
28+
yield 'aria errormessage existing id' => [__DIR__.'/Fixtures/valid/aria_errormessage_existing_id.html.twig', []];
29+
30+
yield 'aria errormessage missing id' => [__DIR__.'/Fixtures/invalid/aria_errormessage_missing_id.html.twig', [
31+
'AriaErrorMessageIdExists.AriaErrorMessage.MissingId:2:1' => 'Referenced id "email-error" in aria-errormessage does not exist in template.',
32+
]];
33+
}
34+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{# 1 errors #}
2+
<input aria-invalid="true" aria-errormessage="email-error">
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<input aria-invalid="true" aria-errormessage="email-error">
2+
<p id="email-error">Invalid email</p>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{# 1 errors #}
2+
<fieldset>
3+
<input type="radio" name="contact" value="email">
4+
<input type="radio" name="contact" value="phone">
5+
</fieldset>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{# 1 errors #}
2+
<div role="radiogroup">
3+
<input type="radio" name="contact" value="email">
4+
<input type="radio" name="contact" value="phone">
5+
</div>

0 commit comments

Comments
 (0)