feat(v3.3.0): enterprise presets, badges, notifications, rule controller, watch mode, offline healer, and WCAG 2.2 ruleset

This commit is contained in:
Enes Yakıştır
2026-09-12 10:12:05 +03:00
parent 2de1f34878
commit ef8ccb9f75
43 changed files with 2598 additions and 81 deletions
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMNode;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LabelInName extends AbstractRule
{
public function getId(): string
{
return 'WCAG_2_5_3_LABEL_IN_NAME';
}
public function getDescription(): string
{
return 'Accessible name must contain the visible text label of interactive controls (WCAG 2.5.3).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
$isInteractive = in_array($tag, ['button', 'a'], true)
|| ($tag === 'input' && in_array(strtolower($element->getAttribute('type')), ['button', 'submit', 'reset'], true))
|| in_array($role, ['button', 'link'], true);
if (!$isInteractive) {
return null;
}
// Avoid template false positives
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
// Determine visible text
$visibleText = '';
if ($tag === 'input') {
$visibleText = trim($element->getAttribute('value'));
} else {
$visibleText = trim($element->textContent);
}
// If there is no visible text (e.g. icon-only button), Label in Name criterion does not apply
if ($visibleText === '' || mb_strlen($visibleText) < 2) {
return null;
}
// Get explicit accessible name
$accessibleName = '';
if ($element->hasAttribute('aria-label')) {
$accessibleName = trim($element->getAttribute('aria-label'));
} elseif ($element->hasAttribute('aria-labelledby')) {
$id = trim($element->getAttribute('aria-labelledby'));
$doc = $element->ownerDocument;
if ($doc !== null && $id !== '') {
$xpath = new DOMXPath($doc);
$nodes = $xpath->query('//*[@id="' . $id . '"]');
if ($nodes !== false && $nodes->length > 0) {
$node = $nodes->item(0);
if ($node instanceof DOMNode) {
$accessibleName = trim($node->textContent);
}
}
}
}
// If no explicit ARIA name is set, the visible text itself is the accessible name
if ($accessibleName === '') {
return null;
}
// Normalization: clean extra whitespace and lower case
$normVisible = mb_strtolower((string)preg_replace('/\s+/', ' ', $visibleText));
$normAcc = mb_strtolower((string)preg_replace('/\s+/', ' ', $accessibleName));
// WCAG 2.5.3: Accessible name must contain the visible text
if (!str_contains($normAcc, $normVisible)) {
return $this->createViolation(
$element,
sprintf(
"Accessible name \"%s\" does not contain the visible text \"%s\".",
$accessibleName,
$visibleText
),
sprintf(
"Update aria-label to include \"%s\" (e.g. aria-label=\"%s %s\") so speech input users can activate the control.",
$visibleText,
$visibleText,
$accessibleName
)
);
}
return null;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class RedundantEntry extends AbstractRule
{
public function getId(): string
{
return 'WCAG_3_3_7_REDUNDANT_ENTRY';
}
public function getDescription(): string
{
return 'Forms with repeated information (such as billing and shipping) must provide auto-population or a selection mechanism (WCAG 2.2 SC 3.3.7).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'form') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
// Find all input and select elements in the form
$inputs = $xpath->query('.//input | .//select | .//textarea', $element);
if ($inputs === false || $inputs->length < 4) {
return null;
}
$hasShipping = false;
$hasBilling = false;
$hasSameCheckbox = false;
foreach ($inputs as $input) {
if (!$input instanceof DOMElement) {
continue;
}
$name = strtolower($input->getAttribute('name'));
$id = strtolower($input->getAttribute('id'));
$autocomplete = strtolower($input->getAttribute('autocomplete'));
$type = strtolower($input->getAttribute('type'));
if (str_contains($name, 'shipping') || str_contains($id, 'shipping') || str_contains($autocomplete, 'shipping')) {
$hasShipping = true;
}
if (str_contains($name, 'billing') || str_contains($id, 'billing') || str_contains($autocomplete, 'billing')) {
$hasBilling = true;
}
if ($type === 'checkbox') {
if (str_contains($name, 'same') || str_contains($id, 'same') || str_contains($name, 'copy') || str_contains($id, 'copy')) {
$hasSameCheckbox = true;
}
}
}
// Also check labels for "same as"
if (!$hasSameCheckbox) {
$labels = $xpath->query('.//label', $element);
if ($labels !== false) {
foreach ($labels as $label) {
if ($label instanceof DOMElement) {
$labelText = strtolower(trim($label->textContent));
if (str_contains($labelText, 'same as') || str_contains($labelText, 'aynı') || str_contains($labelText, 'kopyala')) {
$hasSameCheckbox = true;
break;
}
}
}
}
}
// If form has both shipping and billing fields but no "same as" checkbox
if ($hasShipping && $hasBilling && !$hasSameCheckbox) {
return $this->createViolation(
$element,
"Form requires re-entering shipping and billing information without providing a 'Same as shipping' checkbox or auto-fill option (WCAG 2.2 SC 3.3.7 Redundant Entry).",
"Add a checkbox (e.g. 'Billing address is the same as shipping address') or populate with previously entered user data to reduce cognitive burden."
);
}
return null;
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class RuleController
{
/**
* @var array<string, RuleInterface|AbstractRule>
*/
private array $rulesRegistry = [];
/**
* @param array<int, RuleInterface|AbstractRule>|null $rules
*/
public function __construct(?array $rules = null)
{
$allRules = $rules ?? RuleLevels::getRulesForLevel(9);
foreach ($allRules as $rule) {
$id = ($rule instanceof RuleInterface) ? $rule->getId() : (new \ReflectionClass($rule))->getShortName();
$this->rulesRegistry[$id] = $rule;
}
}
/**
* Get rule instance by ID.
*/
public function getRule(string $ruleId): RuleInterface|AbstractRule|null
{
return $this->rulesRegistry[$ruleId] ?? null;
}
/**
* Execute a specific rule directly on a DOM element.
*/
public function executeRule(string $ruleId, DOMElement $element): ?Violation
{
$rule = $this->getRule($ruleId);
if ($rule === null) {
return null;
}
if ($rule instanceof RuleInterface) {
return $rule->check($element);
}
return null;
}
/**
* Filter active rules by WCAG standard (A, AA, AAA).
*
* @return array<string, RuleInterface|AbstractRule>
*/
public function filterByStandard(WCAGStandard $standard): array
{
$filtered = [];
foreach ($this->rulesRegistry as $id => $rule) {
if (method_exists($rule, 'getStandard') && $rule->getStandard() === $standard) {
$filtered[$id] = $rule;
}
}
return $filtered;
}
/**
* Filter active rules by severity.
*
* @return array<string, RuleInterface|AbstractRule>
*/
public function filterBySeverity(Severity $severity): array
{
$filtered = [];
foreach ($this->rulesRegistry as $id => $rule) {
if (method_exists($rule, 'getSeverity') && $rule->getSeverity() === $severity) {
$filtered[$id] = $rule;
}
}
return $filtered;
}
/**
* Total number of registered rules in this controller.
*/
public function count(): int
{
return count($this->rulesRegistry);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
class RuleExecutionStats
{
/**
* @var array<string, array{invocations: int, total_time_ms: float, violations: int}>
*/
private array $stats = [];
/**
* Record a rule execution metric.
*/
public function record(string $ruleId, float $durationMs, bool $hasViolation = false): void
{
if (!isset($this->stats[$ruleId])) {
$this->stats[$ruleId] = [
'invocations' => 0,
'total_time_ms' => 0.0,
'violations' => 0,
];
}
$this->stats[$ruleId]['invocations']++;
$this->stats[$ruleId]['total_time_ms'] += $durationMs;
if ($hasViolation) {
$this->stats[$ruleId]['violations']++;
}
}
/**
* Get top slowest rules by total execution time.
*
* @return array<string, array{invocations: int, total_time_ms: float, avg_time_ms: float, violations: int}>
*/
public function getSlowestRules(int $limit = 10): array
{
$calculated = [];
foreach ($this->stats as $id => $data) {
$avg = $data['invocations'] > 0 ? ($data['total_time_ms'] / $data['invocations']) : 0.0;
$calculated[$id] = [
'invocations' => $data['invocations'],
'total_time_ms' => round($data['total_time_ms'], 3),
'avg_time_ms' => round($avg, 4),
'violations' => $data['violations'],
];
}
uasort($calculated, function ($a, $b) {
return $b['total_time_ms'] <=> $a['total_time_ms'];
});
return array_slice($calculated, 0, $limit, true);
}
/**
* Get all recorded statistics.
*
* @return array<string, array{invocations: int, total_time_ms: float, violations: int}>
*/
public function getAllStats(): array
{
return $this->stats;
}
/**
* Reset all stats counters.
*/
public function reset(): void
{
$this->stats = [];
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TextSpacing extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_4_12_TEXT_SPACING';
}
public function getDescription(): string
{
return 'Ensure text containers do not restrict line-height or clip content when text spacing is adjusted (WCAG 1.4.12).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::AA;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 4;
}
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('style')) {
return null;
}
$style = strtolower($element->getAttribute('style'));
// Check line-height < 1.2 or <= 12px
if (preg_match('/line-height\s*:\s*([^;]+)/i', $style, $matches)) {
$lh = trim($matches[1]);
// Unitless number check
if (is_numeric($lh) && (float)$lh < 1.2 && (float)$lh > 0) {
return $this->createViolation(
$element,
"Inline line-height is set to {$lh}, which is below the recommended minimum of 1.2 to 1.5.",
"Increase line-height to at least 1.5 to satisfy WCAG 1.4.12 Text Spacing."
);
}
// Pixel check (e.g. 10px, 12px)
if (preg_match('/^(\d+(?:\.\d+)?)px$/i', $lh, $pxMatches)) {
$px = (float)$pxMatches[1];
if ($px < 14) {
return $this->createViolation(
$element,
"Inline line-height is fixed to {$px}px, risking text overlapping when resized.",
"Use relative line-height units (e.g. 1.5) instead of fixed small pixel values."
);
}
}
}
// Check fixed height combined with overflow: hidden on text containers
$textTags = ['p', 'div', 'span', 'article', 'section', 'blockquote', 'li'];
if (in_array(strtolower($element->tagName), $textTags, true)) {
if (str_contains($style, 'overflow:hidden') || str_contains($style, 'overflow: hidden')) {
if (preg_match('/(?:^|;)\s*height\s*:\s*(\d+)px/i', $style, $heightMatches)) {
$height = (int)$heightMatches[1];
$textLength = mb_strlen(trim($element->textContent));
if ($height <= 60 && $textLength > 60) {
return $this->createViolation(
$element,
"Fixed height ({$height}px) with overflow:hidden risks clipping text when user overrides text spacing.",
"Use min-height instead of fixed height or avoid overflow:hidden on text containers."
);
}
}
}
}
return null;
}
}