Skip to content

Commit fd10044

Browse files
Merge pull request #123 from Travelopia/feature/accessibility
Accessibility Round 2
2 parents be7aaed + 048b589 commit fd10044

25 files changed

Lines changed: 1184 additions & 83 deletions

src/form/README.md

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ form.resetValidation();
3333
```html
3434
<tp-form prevent-submit="yes">
3535
<form action="#">
36-
<tp-form-field required="yes" revalidate-on-change="no"> <-- If you don't want to revalidate as the value changes
36+
<tp-form-field required="yes" revalidate-on-change="no"> <!-- If you don't want to revalidate as the value changes -->
3737
<label>Field 1</label>
3838
<input type="text" name="field_1">
3939
</tp-form-field>
@@ -55,7 +55,7 @@ form.resetValidation();
5555
<textarea name="field_4"></textarea>
5656
</tp-form-field>
5757
<tp-form-submit submitting-text="Submitting...">
58-
<button type="submit">Submit</button> <-- There must be a submit button inside this component
58+
<button type="submit">Submit</button> <!-- There must be a submit button inside this component -->
5959
</tp-form-submit>
6060
</form>
6161
</tp-form>
@@ -84,3 +84,142 @@ Validates the form.
8484
### `resetValidation`
8585

8686
Removes all validation errors from the form.
87+
88+
## Error Summary
89+
90+
For accessible form validation, you can add an error summary that lists all validation errors with links to the invalid fields. This follows the [GOV.UK design system pattern](https://design-system.service.gov.uk/components/error-summary/).
91+
92+
The component provides maximum flexibility — you control the markup structure.
93+
94+
```html
95+
<tp-form prevent-submit="yes">
96+
<form action="#">
97+
<tp-form-errors>
98+
<p><tp-form-errors-heading format="$count error(s) found"></tp-form-errors-heading></p>
99+
<tp-form-errors-list role="list"></tp-form-errors-list>
100+
</tp-form-errors>
101+
<!-- form fields here -->
102+
</form>
103+
</tp-form>
104+
```
105+
106+
### Components
107+
108+
| Component | Purpose |
109+
|-----------|---------|
110+
| `tp-form-errors` | Container. Sets `active="yes"` when errors exist. |
111+
| `tp-form-errors-heading` | Displays error count. Use `format` attribute with `$count` placeholder. |
112+
| `tp-form-errors-list` | Contains the list of error links. Add `role="list"` for accessibility. |
113+
| `tp-form-errors-error` | Generated for each error. Has `role="listitem"` auto-applied. |
114+
115+
### Visibility
116+
117+
The error summary is hidden by default. Use CSS to show it when `active="yes"`:
118+
119+
```css
120+
tp-form-errors {
121+
display: none;
122+
}
123+
124+
tp-form-errors[active="yes"] {
125+
display: block;
126+
}
127+
```
128+
129+
### Numbering with CSS Counters
130+
131+
Use CSS counters to number the error list:
132+
133+
```css
134+
tp-form-errors-list {
135+
counter-reset: errors;
136+
}
137+
138+
tp-form-errors-error {
139+
display: block;
140+
counter-increment: errors;
141+
}
142+
143+
tp-form-errors-error::before {
144+
content: counter(errors) ". ";
145+
}
146+
```
147+
148+
### Focus Management
149+
150+
- If `tp-form-errors` exists: Focus moves to the error summary on validation failure
151+
- If `tp-form-errors` doesn't exist: Focus moves to the first visible invalid field
152+
153+
## Accessibility
154+
155+
The form component provides accessibility features while you control the semantic markup.
156+
157+
### What the Component Handles
158+
159+
- **Auto-generates IDs** on form fields if not present
160+
- **Auto-sets `for` attribute** on labels if not present
161+
- **`aria-invalid`** on fields when validation fails
162+
- **`aria-describedby`** linking fields to error messages
163+
- **`role="alert"`** on dynamically created error messages
164+
- **`role="listitem"`** on error summary list items
165+
- **Focus management** to error summary or first visible invalid field
166+
167+
### What You Should Provide
168+
169+
| Attribute | Purpose |
170+
|-----------|---------|
171+
| `aria-required="true"` | On required inputs for screen reader announcements |
172+
| `role="list"` | On `tp-form-errors-list` for proper list semantics |
173+
174+
## Internationalization (i18n)
175+
176+
### Inline Error Messages
177+
178+
Customize inline error messages via `window.tpFormErrors`:
179+
180+
```js
181+
window.tpFormErrors['required'] = 'Ce champ est obligatoire';
182+
window.tpFormErrors['email'] = 'Veuillez entrer une adresse email valide';
183+
```
184+
185+
### Summary Error Messages
186+
187+
Customize summary error messages via `window.tpFormSummaryErrors`. Use `%label%` as a placeholder for the field label:
188+
189+
```js
190+
window.tpFormSummaryErrors['required'] = '%label% est obligatoire';
191+
window.tpFormSummaryErrors['email'] = '%label%: Veuillez entrer une adresse email valide';
192+
```
193+
194+
### Built-in Validators
195+
196+
| Validator | Default Error Message | Default Summary Message |
197+
|-----------|----------------------|-------------------------|
198+
| `required` | This field is required | %label% is required |
199+
| `email` | Please enter a valid email address | %label%: Please enter a valid email address |
200+
| `min-length` | Must be at least %1 characters | %label%: Must be at least %1 characters |
201+
| `max-length` | Must be less than %1 characters | %label%: Must be less than %1 characters |
202+
| `no-empty-spaces` | This field should not contain only white-spaces | %label%: Should not contain only white-spaces |
203+
| `zip` | Please enter a valid zip code | %label%: Please enter a valid zip code |
204+
205+
## Custom Validators
206+
207+
Add custom validators to `window.tpFormValidators`:
208+
209+
```js
210+
window.tpFormValidators['my-validator'] = {
211+
validate: (field) => {
212+
// validation logic
213+
return true;
214+
},
215+
// Inline error message (shown next to field)
216+
getErrorMessage: (field) => 'This field is invalid',
217+
// Summary error message (shown in error summary, optional)
218+
getSummaryMessage: (field) => {
219+
const label = field.querySelector('label')?.textContent || 'Field';
220+
return `${label} is invalid`;
221+
},
222+
};
223+
```
224+
225+
If `getSummaryMessage` is not defined, the component falls back to `getErrorMessage`.

src/form/definitions.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface TPFormValidator {
1010
validate: { ( field: TPFormFieldElement ): boolean | Promise<boolean> };
1111
getErrorMessage: { ( field: TPFormFieldElement ): string };
1212
getSuspenseMessage?: { ( field: TPFormFieldElement ): string };
13+
getSummaryMessage?: { ( field: TPFormFieldElement ): string };
1314
}
1415

1516
/**
@@ -24,6 +25,9 @@ declare global {
2425
tpFormErrors: {
2526
[ key: string ]: string;
2627
};
28+
tpFormSummaryErrors: {
29+
[ key: string ]: string;
30+
};
2731
tpFormSuspenseMessages: {
2832
[ key: string ]: string;
2933
};

src/form/index.html

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,44 @@
2828
textarea {
2929
width: 100%;
3030
}
31+
32+
/* Error summary - hidden by default, shown when active */
33+
tp-form-errors {
34+
display: none;
35+
}
36+
37+
tp-form-errors[active="yes"] {
38+
display: block;
39+
background: #fef2f2;
40+
border: 1px solid #ef4444;
41+
border-radius: 4px;
42+
padding: 16px;
43+
margin-bottom: 16px;
44+
}
45+
46+
tp-form-errors[active="yes"] p {
47+
color: #dc2626;
48+
font-size: 1rem;
49+
font-weight: bold;
50+
margin: 0 0 8px 0;
51+
}
52+
53+
tp-form-errors-list {
54+
counter-reset: errors;
55+
}
56+
57+
tp-form-errors-error {
58+
display: block;
59+
counter-increment: errors;
60+
}
61+
62+
tp-form-errors-error::before {
63+
content: counter(errors) ". ";
64+
}
65+
66+
tp-form-errors[active="yes"] a {
67+
color: #dc2626;
68+
}
3169
</style>
3270

3371
<script type="module">
@@ -48,7 +86,11 @@
4886
<main>
4987
<tp-form prevent-submit="yes">
5088
<form action="#">
51-
<h3>Synchronous Form</h3>
89+
<h3>Synchronous Form (with visible error summary)</h3>
90+
<tp-form-errors>
91+
<p><tp-form-errors-heading format="$count error(s) found in the form"></tp-form-errors-heading></p>
92+
<tp-form-errors-list role="list"></tp-form-errors-list>
93+
</tp-form-errors>
5294
<tp-form-field no-empty-spaces="yes" required="yes">
5395
<label>Field 1</label>
5496
<input type="text" name="field_1">

src/form/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,21 @@ const validators = [
3131
*/
3232
window.tpFormValidators = {};
3333
window.tpFormErrors = {};
34+
window.tpFormSummaryErrors = {};
3435
window.tpFormSuspenseMessages = {};
3536

3637
// Register validators.
3738
validators.forEach( (
38-
{ name, validator, errorMessage }: { name: string, validator: TPFormValidator, errorMessage: string }
39+
{ name, validator, errorMessage, summaryErrorMessage }: { name: string, validator: TPFormValidator, errorMessage: string, summaryErrorMessage?: string }
3940
): void => {
4041
// Assigning validators and error messages to various fields.
4142
window.tpFormValidators[ name ] = validator;
4243
window.tpFormErrors[ name ] = errorMessage;
44+
45+
// Register summary error message if provided.
46+
if ( summaryErrorMessage ) {
47+
window.tpFormSummaryErrors[ name ] = summaryErrorMessage;
48+
}
4349
} );
4450

4551
/**
@@ -48,6 +54,10 @@ validators.forEach( (
4854
import { TPFormElement } from './tp-form';
4955
import { TPFormFieldElement } from './tp-form-field';
5056
import { TPFormErrorElement } from './tp-form-error';
57+
import { TPFormErrorsElement } from './tp-form-errors';
58+
import { TPFormErrorsHeadingElement } from './tp-form-errors-heading';
59+
import { TPFormErrorsListElement } from './tp-form-errors-list';
60+
import { TPFormErrorsErrorElement } from './tp-form-errors-error';
5161
import { TPFormSuspenseElement } from './tp-form-suspense';
5262
import { TPFormSubmitElement } from './tp-form-submit';
5363

@@ -57,5 +67,9 @@ import { TPFormSubmitElement } from './tp-form-submit';
5767
customElements.define( 'tp-form', TPFormElement );
5868
customElements.define( 'tp-form-field', TPFormFieldElement );
5969
customElements.define( 'tp-form-error', TPFormErrorElement );
70+
customElements.define( 'tp-form-errors', TPFormErrorsElement );
71+
customElements.define( 'tp-form-errors-heading', TPFormErrorsHeadingElement );
72+
customElements.define( 'tp-form-errors-list', TPFormErrorsListElement );
73+
customElements.define( 'tp-form-errors-error', TPFormErrorsErrorElement );
6074
customElements.define( 'tp-form-suspense', TPFormSuspenseElement );
6175
customElements.define( 'tp-form-submit', TPFormSubmitElement );

src/form/tp-form-errors-error.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* TP Form Errors Error.
3+
*/
4+
export class TPFormErrorsErrorElement extends HTMLElement {
5+
/**
6+
* Constructor.
7+
*/
8+
constructor() {
9+
// Initialize parent.
10+
super();
11+
12+
// Use event delegation to handle clicks on dynamically added anchors.
13+
this.addEventListener( 'click', this.handleClick.bind( this ) );
14+
}
15+
16+
/**
17+
* Handle click on error link.
18+
*
19+
* @param {Event} event Click event.
20+
*/
21+
protected handleClick( event: Event ): void {
22+
// Find the anchor element.
23+
const target = event.target as HTMLElement;
24+
const anchor = target.closest( 'a' );
25+
26+
// Only handle clicks on anchors.
27+
if ( ! anchor ) {
28+
// Bail early.
29+
return;
30+
}
31+
32+
// Prevent default to avoid hash in URL.
33+
event.preventDefault();
34+
35+
// Get the field ID from href.
36+
const href = anchor.getAttribute( 'href' ) ?? '';
37+
const fieldId = href.replace( '#', '' );
38+
39+
// Focus the target field.
40+
if ( fieldId ) {
41+
const targetField = document.getElementById( fieldId );
42+
targetField?.focus();
43+
}
44+
}
45+
}

src/form/tp-form-errors-heading.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* TP Form Errors Heading.
3+
*
4+
* Displays the error count. User controls the heading element wrapper.
5+
*/
6+
export class TPFormErrorsHeadingElement extends HTMLElement {
7+
/**
8+
* Get format.
9+
*
10+
* @return {string} Format with $count placeholder.
11+
*/
12+
get format(): string {
13+
// Get format.
14+
return this.getAttribute( 'format' ) ?? '';
15+
}
16+
17+
/**
18+
* Set format.
19+
*
20+
* @param {string} format Format string.
21+
*/
22+
set format( format: string ) {
23+
// Set format.
24+
this.setAttribute( 'format', format );
25+
}
26+
27+
/**
28+
* Update the heading with the error count.
29+
*
30+
* @param {number} count Number of errors.
31+
*/
32+
update( count: number ): void {
33+
// Update count.
34+
this.textContent = this.format.replace( '$count', count.toString() );
35+
}
36+
}

0 commit comments

Comments
 (0)