计划执行与检查节点
executing-plans
按既定实施计划完成工作,保持过程检查和反馈。
wcag-form-accessibility-php-laravel【效率与协作】Implements WCAG 2.2 AA accessible form patterns in PHP/Laravel using semantic HTML, aria-describedby error linking, fieldset/legend grouping, focus management, and server-rendered form practices for guaranteed accessibility compliance.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
This skill enables server-rendered form patterns that achieve WCAG 2.2 AA compliance through semantic HTML, proper label association, error message linking, and focus management. Load this skill when implementing or reviewing forms in Laravel, plain PHP, or WordPress plugins.
Use this skill when:
Avoid this skill for:
<label> with matching for attribute (WCAG 1.3.1)aria-describedby on input element (WCAG 3.3.1)<fieldset> with <legend> (WCAG 1.3.1)Establish Semantic Structure — Use <form>, <fieldset>, <legend>, and semantic input types (email, tel, date). Checkpoint: Validate form element contains all inputs; fieldsets group related inputs.
Link Labels to Inputs — Every input must have a <label> with for attribute matching the input id. Checkpoint: Run form.elements[name].labels[0].htmlFor in browser console; should return the input's id.
Associate Error Messages — Link error messages to inputs via aria-describedby. Error message element must have a unique id matching the input's aria-describedby value. Checkpoint: Inspect input.getAttribute('aria-describedby') and verify corresponding element exists and contains error text.
Implement Error Summary — Create error summary page with all validation errors linked to problem inputs. Focus first error element on page load. Checkpoint: Tab to each error link; verify it jumps to the problematic input, which receives focus and has error message visible.
Manage Focus State — On form submission failure, restore focus to first error input or error summary. Preserve form data via Laravel's old() helper or Livewire's wire:model. Checkpoint: Submit invalid form; verify focus moves to error summary or first problematic input.
Test with Screen Readers — Use NVDA (free), JAWS, or VoiceOver to verify form is announced correctly. Screen reader should announce label, required status, and error message for each input. Checkpoint: Load page with screen reader; listen for full form instruction before attempting interaction.
Lint Blade Templates — Integrate laravel-blade-ally into CI/CD to catch accessibility violations in pull requests. Fails on missing labels, unlinked errors, and invalid ARIA attributes. Checkpoint: Run php artisan blade-ally before merge; zero warnings.
The foundation of form accessibility is connecting labels to inputs. Every input must have a corresponding <label> element with a for attribute matching the input's id.
Blade Template:
<form action="{{ route('contact.store') }}" method="POST">
@csrf
<div class="form-group">
<label for="email">Email Address <span aria-label="required">*</span></label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
value="{{ old('email') }}"
@error('email') aria-invalid="true" @enderror
/>
</div>
<button type="submit">Submit</button>
</form>
Key Points:
id="email" on input matches for="email" on labelaria-required="true" announces required status to assistive technologyaria-invalid="true" set when validation error existsvalue="{{ old('email') }}" preserves user input on validation failure (WCAG 3.3.7)<span aria-label="required">*</span> ensures asterisk is announced as "required"Common Pitfall — Missing for attribute:
<!-- ❌ BAD — label not linked to input -->
<label>Email Address</label>
<input type="email" name="email" />
<!-- ✅ GOOD — label linked via for attribute -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email" />
Error messages must be associated with their input using aria-describedby. The error message element needs a unique id that the input references.
Blade Template with Laravel Validation Errors:
<form action="{{ route('register.store') }}" method="POST">
@csrf
<div class="form-group">
<label for="password">Password <span aria-label="required">*</span></label>
<input
type="password"
id="password"
name="password"
required
aria-required="true"
@error('password')
aria-describedby="password-error"
aria-invalid="true"
@enderror
/>
@error('password')
<div id="password-error" role="alert" class="error-message">
{{ $message }}
</div>
@enderror
<!-- Help text supplementing error message -->
<div id="password-help" class="help-text">
Minimum 8 characters, at least one uppercase letter and one number.
</div>
</div>
<button type="submit">Create Account</button>
</form>
Key Points:
aria-describedby="password-error" links input to error message elementid="password-error" matching aria-describedby valuerole="alert" announces error immediately to screen readersaria-describedby is not set (only set when error exists)PHP Controller Example:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
public function store(Request $request)
{
// Guard clause: validate input at boundary
$validated = $request->validate([
'email' => 'required|email|unique:users',
'password' => ['required', Password::min(8)
->mixedCase()
->numbers()],
]);
// Data now safely parsed and trusted
$user = User::create($validated);
return redirect()->route('register.success');
}
}
Multi-Error Pattern — Multiple Descriptions:
<input
type="password"
id="password"
name="password"
aria-describedby="password-error password-help"
@error('password') aria-invalid="true" @enderror
/>
@error('password')
<div id="password-error" role="alert">{{ $message }}</div>
@enderror
<div id="password-help" class="help-text">
8+ chars, uppercase, number required.
</div>
Radio buttons and checkboxes must be grouped using <fieldset> with a <legend> describing the group. Individual inputs within the group still need labels.
Blade Template — Radio Group Example:
<fieldset class="form-fieldset">
<legend class="form-legend">
How would you like to be contacted?
<span aria-label="required">*</span>
</legend>
<div class="radio-group">
<div class="radio-option">
<input
type="radio"
id="contact-email"
name="contact_method"
value="email"
required
@checked(old('contact_method') === 'email')
/>
<label for="contact-email">Email</label>
</div>
<div class="radio-option">
<input
type="radio"
id="contact-phone"
name="contact_method"
value="phone"
@checked(old('contact_method') === 'phone')
/>
<label for="contact-phone">Phone</label>
</div>
<div class="radio-option">
<input
type="radio"
id="contact-sms"
name="contact_method"
value="sms"
@checked(old('contact_method') === 'sms')
/>
<label for="contact-sms">SMS</label>
</div>
</div>
@error('contact_method')
<div id="contact-method-error" role="alert" class="error-message">
{{ $message }}
</div>
@enderror
</fieldset>
Key Points:
<fieldset> wraps entire group<legend> describes the group purposeid and matching label for attribute@checked() preserves selection on validation failureCheckbox Group Pattern:
<fieldset class="form-fieldset">
<legend class="form-legend">
Select your interests
<span aria-label="required">*</span>
</legend>
<div class="checkbox-group">
@foreach(['web' => 'Web Development', 'mobile' => 'Mobile Apps', 'devops' => 'DevOps'] as $value => $label)
<div class="checkbox-option">
<input
type="checkbox"
id="interest-{{ $value }}"
name="interests[]"
value="{{ $value }}"
@checked(in_array($value, (array)old('interests', [])))
/>
<label for="interest-{{ $value }}">{{ $label }}</label>
</div>
@endforeach
</div>
@error('interests')
<div id="interests-error" role="alert" class="error-message">
{{ $message }}
</div>
@enderror
</fieldset>
Common Pitfall — Missing Fieldset:
<!-- ❌ BAD — no fieldset, screen reader announces three separate inputs -->
<label><input type="radio" name="contact" value="email" /> Email</label>
<label><input type="radio" name="contact" value="phone" /> Phone</label>
<label><input type="radio" name="contact" value="sms" /> SMS</label>
<!-- ✅ GOOD — fieldset groups inputs and announces shared context -->
<fieldset>
<legend>How to contact you?</legend>
<label><input type="radio" name="contact" value="email" /> Email</label>
<label><input type="radio" name="contact" value="phone" /> Phone</label>
<label><input type="radio" name="contact" value="sms" /> SMS</label>
</fieldset>
On form submission failure, display an error summary at the top of the page with focus restoration to help users correct mistakes quickly.
Blade Template with Error Summary:
<form action="{{ route('checkout.process') }}" method="POST">
@csrf
@if ($errors->any())
<div role="region" aria-labelledby="error-summary-heading" class="error-summary">
<h2 id="error-summary-heading">Please correct the following errors:</h2>
<ul>
@foreach ($errors->all() as $error)
<li>
<a href="#{{ $error->get('key') }}" class="error-link">
{{ $error }}
</a>
</li>
@endforeach
</ul>
</div>
@endif
<div class="form-group">
<label for="billing-name">Full Name <span aria-label="required">*</span></label>
<input
type="text"
id="billing-name"
name="name"
required
value="{{ old('name') }}"
@error('name') aria-invalid="true" aria-describedby="name-error" @enderror
/>
@error('name')
<div id="name-error" role="alert" class="error-message">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="billing-address">Address <span aria-label="required">*</span></label>
<input
type="text"
id="billing-address"
name="address"
required
value="{{ old('address') }}"
@error('address') aria-invalid="true" aria-describedby="address-error" @enderror
/>
@error('address')
<div id="address-error" role="alert" class="error-message">{{ $message }}</div>
@enderror
</div>
<button type="submit" id="submit-btn">Complete Purchase</button>
</form>
PHP Controller with Focus Restoration:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function process(Request $request)
{
// Parse inputs at boundary; fail loud on validation error
$validated = $request->validate([
'name' => 'required|string|max:255',
'address' => 'required|string|max:500',
'email' => 'required|email',
]);
// Data is now trusted; proceed with business logic
$order = Order::create($validated);
return redirect()->route('checkout.success', $order);
}
}
JavaScript Focus Restoration (Blade snippet):
@if ($errors->any())
<script>
// Focus first error's input on page load
const firstErrorInput = document.querySelector('[aria-invalid="true"]');
if (firstErrorInput) {
firstErrorInput.focus();
}
// Alternative: focus error summary heading
// const errorSummary = document.getElementById('error-summary-heading');
// if (errorSummary) {
// errorSummary.focus();
// errorSummary.tabIndex = -1; // Make focusable
// }
</script>
@endif
Livewire components enable real-time form validation while maintaining accessibility through aria-live regions and focus management.
Livewire Component Class:
<?php
namespace App\Livewire\Forms;
use Livewire\Component;
use Livewire\Attributes\Validate;
class SubscriptionForm extends Component
{
#[Validate('required|email|unique:subscribers')]
public string $email = '';
#[Validate('required|min:3')]
public string $name = '';
public bool $hasSubmitted = false;
public function updated($propertyName)
{
// Real-time validation on field blur
$this->validateOnly($propertyName);
}
public function subscribe()
{
// Guard clause: validate at boundary
$validated = $this->validate();
Subscriber::create($validated);
$this->reset();
$this->hasSubmitted = true;
}
public function render()
{
return view('livewire.subscription-form');
}
}
Livewire Blade Component Template:
<form wire:submit="subscribe" class="subscription-form">
<h2>Subscribe to Newsletter</h2>
<!-- Error Summary with aria-live announcement -->
@if ($errors->any())
<div role="alert" aria-live="assertive" aria-atomic="true" class="error-summary">
<p>{{ count($errors) }} error(s) found. Please review and correct:</p>
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<!-- Email Input with Real-Time Validation -->
<div class="form-group">
<label for="subscriber-email">Email Address <span aria-label="required">*</span></label>
<input
type="email"
id="subscriber-email"
wire:model.blur="email"
placeholder="you@example.com"
required
@error('email')
aria-invalid="true"
aria-describedby="email-error"
@enderror
/>
@error('email')
<div id="email-error" role="alert" class="error-message">
{{ $message }}
</div>
@else
<div id="email-help" class="help-text">
We'll send you updates about new features.
</div>
@enderror
</div>
<!-- Name Input with Real-Time Validation -->
<div class="form-group">
<label for="subscriber-name">Full Name <span aria-label="required">*</span></label>
<input
type="text"
id="subscriber-name"
wire:model.blur="name"
required
@error('name')
aria-invalid="true"
aria-describedby="name-error"
@enderror
/>
@error('name')
<div id="name-error" role="alert" class="error-message">
{{ $message }}
</div>
@endif
</div>
<!-- Submit Button with Loading State -->
<button
type="submit"
wire:loading.attr="disabled"
wire:loading.class="opacity-50"
aria-busy="false"
wire:loading.attr="aria-busy=true"
>
<span wire:loading.remove>Subscribe</span>
<span wire:loading>Subscribing...</span>
</button>
<!-- Success Message (aria-live announcement) -->
@if ($hasSubmitted && !$errors->any())
<div role="status" aria-live="polite" class="success-message">
✓ Successfully subscribed! Check your email to confirm.
</div>
@endif
</form>
Key Points:
wire:model.blur="email" triggers validation on field blur (reduces chattiness)role="alert" aria-live="assertive" announces validation errors immediatelyaria-busy="true" set during form submission (screen reader announces loading state)aria-live="polite" (waits for pause in speech)Enhance WordPress form plugins with accessibility without forking them. Use hooks to inject ARIA attributes and error linking.
Contact Form 7 Accessibility Filter (functions.php or plugin):
<?php
/**
* Enhance Contact Form 7 with WCAG 2.2 AA accessibility.
* Adds aria-describedby linking and validates label association.
*/
add_filter('wpcf7_form_elements', function($html) {
// Parse form HTML
$dom = new DOMDocument();
@$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOEMPTYTAGS);
// Add aria-describedby to inputs and link error messages
$inputs = $dom->getElementsByTagName('input');
foreach ($inputs as $input) {
$name = $input->getAttribute('name');
if (!$name) continue;
// Generate error message ID
$errorId = $name . '-error';
// Set aria-describedby on input (if field is required)
if ($input->getAttribute('aria-required') === 'true') {
$input->setAttribute('aria-describedby', $errorId);
}
// Verify label exists
$labels = $dom->getElementsByTagName('label');
$labelExists = false;
foreach ($labels as $label) {
if ($label->getAttribute('for') === $input->getAttribute('id')) {
$labelExists = true;
break;
}
}
if (!$labelExists) {
// Log missing label for developer review
error_log("Missing label for input: {$name}");
}
}
// Convert back to HTML string
return $dom->saveHTML();
});
/**
* Enhance Gravity Forms with WCAG 2.2 AA accessibility.
* Links error messages to form fields via aria-describedby.
*/
add_filter('gform_field_container', function($container, $field, $form, $cssClass) {
// Guard clause: only process text/email/date inputs
if (!in_array($field->type, ['text', 'email', 'date', 'phone', 'number'])) {
return $container;
}
// Generate error message ID
$errorId = 'field_' . $field->id . '_error';
// Inject aria-describedby into field markup
$container = str_replace(
'type="' . $field->type . '"',
'type="' . $field->type . '" aria-describedby="' . $errorId . '"',
$container
);
return $container;
}, 10, 4);
add_filter('gform_validation_message', function($message, $form) {
// Replace generic error wrapper with accessible one
$message = str_replace(
'<div class="validation_error">',
'<div class="validation_error" role="alert" aria-live="assertive">',
$message
);
return $message;
}, 10, 2);
Common WordPress Form Pitfalls — Fixed:
<?php
// ❌ BAD — Contact Form 7 default (no aria-describedby linking)
<input type="email" name="your-email" />
<span class="wpcf7-form-control-wrap your-email">
<span class="your-email-error" style="display:none;">Invalid email</span>
</span>
// ✅ GOOD — Enhanced with accessibility hook
<input
type="email"
name="your-email"
id="your-email"
aria-describedby="your-email-error"
aria-required="true"
/>
<span class="wpcf7-form-control-wrap your-email">
<span id="your-email-error" role="alert" class="your-email-error">Invalid email</span>
</span>
Integrate laravel-blade-ally into your CI/CD pipeline to catch accessibility violations before code review.
Laravel Service Provider (register in app/Providers/AppServiceProvider.php):
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Accessibility linting only in development
if ($this->app->environment('local', 'testing')) {
// laravel-blade-ally checks will run automatically
}
}
}
Artisan Command Usage:
# Check all Blade templates for accessibility violations
php artisan blade-ally
# Check specific directory
php artisan blade-ally --path=resources/views/forms
# Fail on warnings (for CI/CD)
php artisan blade-ally --fail-on-warnings
# Generate JSON report
php artisan blade-ally --json > accessibility-report.json
Example CI Configuration (.github/workflows/accessibility.yml):
name: Accessibility Linting
on: [pull_request]
jobs:
accessibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install Dependencies
run: composer install
- name: Run Accessibility Lint
run: php artisan blade-ally --fail-on-warnings
- name: Generate Report
if: always()
run: php artisan blade-ally --json > accessibility-report.json
- name: Upload Report
if: always()
uses: actions/upload-artifact@v3
with:
name: accessibility-report
path: accessibility-report.json
Common laravel-blade-ally Violations:
<!-- ❌ VIOLATION: Missing label for attribute -->
<input type="email" id="user-email" name="email" />
<!-- ✅ FIXED: Label linked to input -->
<label for="user-email">Email</label>
<input type="email" id="user-email" name="email" />
<!-- ❌ VIOLATION: Error message not linked -->
<input type="password" name="password" />
<span class="error">Password required</span>
<!-- ✅ FIXED: Error linked via aria-describedby -->
<input type="password" name="password" aria-describedby="password-error" />
<span id="password-error" role="alert">Password required</span>
<!-- ❌ VIOLATION: Radio group missing fieldset/legend -->
<label><input type="radio" name="choice" /> Option A</label>
<label><input type="radio" name="choice" /> Option B</label>
<!-- ✅ FIXED: Fieldset groups related inputs -->
<fieldset>
<legend>Choose one:</legend>
<label><input type="radio" name="choice" /> Option A</label>
<label><input type="radio" name="choice" /> Option B</label>
</fieldset>
<label for="id"> matching input id attribute. This is the foundation of form accessibility (WCAG 1.3.1).aria-describedby pointing to the error element's id. Only set aria-describedby when an error exists.<fieldset> and describe the group with <legend>. Screen readers announce the group context before each option.old() helper or Livewire's wire:model. Users should never lose their input when validation fails (WCAG 3.3.7).aria-invalid="true" on inputs with validation errors. Remove when field is corrected. This alerts screen readers to invalid state immediately.aria-required="true" on required inputs in addition to the required HTML attribute. Some screen readers don't detect required attribute without ARIA reinforcement.email, tel, date, number) to enable browser validation and optimize mobile keyboards.role="alert" and aria-live="assertive" on error messages and error summaries. Screen readers should announce errors without waiting for user action.focus() and tabIndex = -1 for headings.placeholder text as a label substitute. Placeholders disappear when the user starts typing; screen reader users lose context. Always use explicit <label> elements.display: none or visibility: hidden. Use CSS to visually hide but keep in the DOM so assistive technology can read them. Example: position: absolute; left: -9999px; or clip: rect(0,0,0,0).type attribute on inputs. type="email" enables browser validation and mobile keyboard optimization. Do not use <div contenteditable> as a form input substitute.aria-describedby. Screen readers announce them in the correct context.<input type="checkbox"> will fail keyboard navigation and screen reader detection. Use semantic HTML first; style with CSS.When implementing an accessible form pattern, your output must include:
Blade Template — Complete form markup with labels, inputs, error messages, and accessibility attributes (for, aria-describedby, aria-invalid, aria-required, fieldsets, legends).
Controller or Component — Server-side validation logic parsing inputs at the boundary, setting error messages, and preserving form data via old() helper or Livewire wire:model.
Error Handling — Specific error messages linked to inputs via aria-describedby. Error summary (if applicable) with focus management.
CI Integration — Command to run laravel-blade-ally or equivalent accessibility linter. Example: php artisan blade-ally --fail-on-warnings.
Test Coverage — Screen reader test checklist: Does the form announce labels, required status, error messages, and help text correctly? Can the form be completed using only keyboard and screen reader?
WCAG Criteria Satisfied — Document which WCAG 2.2 AA criteria are met:
Use these checklists before shipping accessible forms:
:focus-visible)<label for="id"> elementsaria-describedbyaria-required="true" set on required inputsaria-invalid="true" set on invalid inputs (only when invalid)role="alert" on error messages and error summariesold() or wire:model on validation failureThis skill implements the following WCAG 2.2 AA criteria:
aria-live without moving focus.placeholder as a label substitute — Placeholders are not accessible and disappear during user input.display: none — Screen readers can't read hidden content. Use clip: rect(0,0,0,0) or position: absolute; left: -9999px; instead.aria-invalid when validation fails — Screen readers don't know the field is invalid without this attribute.<fieldset>/<legend> — Screen readers can't announce that the options are grouped; each appears as an isolated radio button.aria-describedby — Screen readers don't associate the error with the input.<input type="checkbox"> elements will fail accessibility testing.Test coverage metrics for this skill:
This skill is production-ready for Laravel and PHP/WordPress projects. Use it when implementing or auditing form accessibility.