Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 141 additions & 2 deletions src/form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ form.resetValidation();
```html
<tp-form prevent-submit="yes">
<form action="#">
<tp-form-field required="yes" revalidate-on-change="no"> <-- If you don't want to revalidate as the value changes
<tp-form-field required="yes" revalidate-on-change="no"> <!-- If you don't want to revalidate as the value changes -->
<label>Field 1</label>
<input type="text" name="field_1">
</tp-form-field>
Expand All @@ -55,7 +55,7 @@ form.resetValidation();
<textarea name="field_4"></textarea>
</tp-form-field>
<tp-form-submit submitting-text="Submitting...">
<button type="submit">Submit</button> <-- There must be a submit button inside this component
<button type="submit">Submit</button> <!-- There must be a submit button inside this component -->
</tp-form-submit>
</form>
</tp-form>
Expand Down Expand Up @@ -84,3 +84,142 @@ Validates the form.
### `resetValidation`

Removes all validation errors from the form.

## Error Summary

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/).

The component provides maximum flexibility — you control the markup structure.

```html
<tp-form prevent-submit="yes">
<form action="#">
<tp-form-errors>
<p><tp-form-errors-heading format="$count error(s) found"></tp-form-errors-heading></p>
<tp-form-errors-list role="list"></tp-form-errors-list>
</tp-form-errors>
<!-- form fields here -->
</form>
</tp-form>
```

### Components

| Component | Purpose |
|-----------|---------|
| `tp-form-errors` | Container. Sets `active="yes"` when errors exist. |
| `tp-form-errors-heading` | Displays error count. Use `format` attribute with `$count` placeholder. |
| `tp-form-errors-list` | Contains the list of error links. Add `role="list"` for accessibility. |
| `tp-form-errors-error` | Generated for each error. Has `role="listitem"` auto-applied. |

### Visibility

The error summary is hidden by default. Use CSS to show it when `active="yes"`:

```css
tp-form-errors {
display: none;
}

tp-form-errors[active="yes"] {
display: block;
}
```

### Numbering with CSS Counters

Use CSS counters to number the error list:

```css
tp-form-errors-list {
counter-reset: errors;
}

tp-form-errors-error {
display: block;
counter-increment: errors;
}

tp-form-errors-error::before {
content: counter(errors) ". ";
}
```

### Focus Management

- If `tp-form-errors` exists: Focus moves to the error summary on validation failure
- If `tp-form-errors` doesn't exist: Focus moves to the first visible invalid field

## Accessibility

The form component provides accessibility features while you control the semantic markup.

### What the Component Handles

- **Auto-generates IDs** on form fields if not present
- **Auto-sets `for` attribute** on labels if not present
- **`aria-invalid`** on fields when validation fails
- **`aria-describedby`** linking fields to error messages
- **`role="alert"`** on dynamically created error messages
- **`role="listitem"`** on error summary list items
- **Focus management** to error summary or first visible invalid field

### What You Should Provide

| Attribute | Purpose |
|-----------|---------|
| `aria-required="true"` | On required inputs for screen reader announcements |
| `role="list"` | On `tp-form-errors-list` for proper list semantics |

## Internationalization (i18n)

### Inline Error Messages

Customize inline error messages via `window.tpFormErrors`:

```js
window.tpFormErrors['required'] = 'Ce champ est obligatoire';
window.tpFormErrors['email'] = 'Veuillez entrer une adresse email valide';
```

### Summary Error Messages

Customize summary error messages via `window.tpFormSummaryErrors`. Use `%label%` as a placeholder for the field label:

```js
window.tpFormSummaryErrors['required'] = '%label% est obligatoire';
window.tpFormSummaryErrors['email'] = '%label%: Veuillez entrer une adresse email valide';
```

### Built-in Validators

| Validator | Default Error Message | Default Summary Message |
|-----------|----------------------|-------------------------|
| `required` | This field is required | %label% is required |
| `email` | Please enter a valid email address | %label%: Please enter a valid email address |
| `min-length` | Must be at least %1 characters | %label%: Must be at least %1 characters |
| `max-length` | Must be less than %1 characters | %label%: Must be less than %1 characters |
| `no-empty-spaces` | This field should not contain only white-spaces | %label%: Should not contain only white-spaces |
| `zip` | Please enter a valid zip code | %label%: Please enter a valid zip code |

## Custom Validators

Add custom validators to `window.tpFormValidators`:

```js
window.tpFormValidators['my-validator'] = {
validate: (field) => {
// validation logic
return true;
},
// Inline error message (shown next to field)
getErrorMessage: (field) => 'This field is invalid',
// Summary error message (shown in error summary, optional)
getSummaryMessage: (field) => {
const label = field.querySelector('label')?.textContent || 'Field';
return `${label} is invalid`;
},
};
```

If `getSummaryMessage` is not defined, the component falls back to `getErrorMessage`.
4 changes: 4 additions & 0 deletions src/form/definitions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface TPFormValidator {
validate: { ( field: TPFormFieldElement ): boolean | Promise<boolean> };
getErrorMessage: { ( field: TPFormFieldElement ): string };
getSuspenseMessage?: { ( field: TPFormFieldElement ): string };
getSummaryMessage?: { ( field: TPFormFieldElement ): string };
}

/**
Expand All @@ -24,6 +25,9 @@ declare global {
tpFormErrors: {
[ key: string ]: string;
};
tpFormSummaryErrors: {
[ key: string ]: string;
};
tpFormSuspenseMessages: {
[ key: string ]: string;
};
Expand Down
44 changes: 43 additions & 1 deletion src/form/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,44 @@
textarea {
width: 100%;
}

/* Error summary - hidden by default, shown when active */
tp-form-errors {
display: none;
}

tp-form-errors[active="yes"] {
display: block;
background: #fef2f2;
border: 1px solid #ef4444;
border-radius: 4px;
padding: 16px;
margin-bottom: 16px;
}

tp-form-errors[active="yes"] p {
color: #dc2626;
font-size: 1rem;
font-weight: bold;
margin: 0 0 8px 0;
}

tp-form-errors-list {
counter-reset: errors;
}

tp-form-errors-error {
display: block;
counter-increment: errors;
}

tp-form-errors-error::before {
content: counter(errors) ". ";
}

tp-form-errors[active="yes"] a {
color: #dc2626;
}
</style>

<script type="module">
Expand All @@ -48,7 +86,11 @@
<main>
<tp-form prevent-submit="yes">
<form action="#">
<h3>Synchronous Form</h3>
<h3>Synchronous Form (with visible error summary)</h3>
<tp-form-errors>
<p><tp-form-errors-heading format="$count error(s) found in the form"></tp-form-errors-heading></p>
<tp-form-errors-list role="list"></tp-form-errors-list>
</tp-form-errors>
<tp-form-field no-empty-spaces="yes" required="yes">
<label>Field 1</label>
<input type="text" name="field_1">
Expand Down
16 changes: 15 additions & 1 deletion src/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@ const validators = [
*/
window.tpFormValidators = {};
window.tpFormErrors = {};
window.tpFormSummaryErrors = {};
window.tpFormSuspenseMessages = {};

// Register validators.
validators.forEach( (
{ name, validator, errorMessage }: { name: string, validator: TPFormValidator, errorMessage: string }
{ name, validator, errorMessage, summaryErrorMessage }: { name: string, validator: TPFormValidator, errorMessage: string, summaryErrorMessage?: string }
): void => {
// Assigning validators and error messages to various fields.
window.tpFormValidators[ name ] = validator;
window.tpFormErrors[ name ] = errorMessage;

// Register summary error message if provided.
if ( summaryErrorMessage ) {
window.tpFormSummaryErrors[ name ] = summaryErrorMessage;
}
} );

/**
Expand All @@ -48,6 +54,10 @@ validators.forEach( (
import { TPFormElement } from './tp-form';
import { TPFormFieldElement } from './tp-form-field';
import { TPFormErrorElement } from './tp-form-error';
import { TPFormErrorsElement } from './tp-form-errors';
import { TPFormErrorsHeadingElement } from './tp-form-errors-heading';
import { TPFormErrorsListElement } from './tp-form-errors-list';
import { TPFormErrorsErrorElement } from './tp-form-errors-error';
import { TPFormSuspenseElement } from './tp-form-suspense';
import { TPFormSubmitElement } from './tp-form-submit';

Expand All @@ -57,5 +67,9 @@ import { TPFormSubmitElement } from './tp-form-submit';
customElements.define( 'tp-form', TPFormElement );
customElements.define( 'tp-form-field', TPFormFieldElement );
customElements.define( 'tp-form-error', TPFormErrorElement );
customElements.define( 'tp-form-errors', TPFormErrorsElement );
customElements.define( 'tp-form-errors-heading', TPFormErrorsHeadingElement );
customElements.define( 'tp-form-errors-list', TPFormErrorsListElement );
customElements.define( 'tp-form-errors-error', TPFormErrorsErrorElement );
customElements.define( 'tp-form-suspense', TPFormSuspenseElement );
customElements.define( 'tp-form-submit', TPFormSubmitElement );
45 changes: 45 additions & 0 deletions src/form/tp-form-errors-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* TP Form Errors Error.
*/
export class TPFormErrorsErrorElement extends HTMLElement {
/**
* Constructor.
*/
constructor() {
// Initialize parent.
super();

// Use event delegation to handle clicks on dynamically added anchors.
this.addEventListener( 'click', this.handleClick.bind( this ) );
}

/**
* Handle click on error link.
*
* @param {Event} event Click event.
*/
protected handleClick( event: Event ): void {
// Find the anchor element.
const target = event.target as HTMLElement;
const anchor = target.closest( 'a' );

// Only handle clicks on anchors.
if ( ! anchor ) {
// Bail early.
return;
}

// Prevent default to avoid hash in URL.
event.preventDefault();

// Get the field ID from href.
const href = anchor.getAttribute( 'href' ) ?? '';
const fieldId = href.replace( '#', '' );

// Focus the target field.
if ( fieldId ) {
const targetField = document.getElementById( fieldId );
targetField?.focus();
}
}
}
36 changes: 36 additions & 0 deletions src/form/tp-form-errors-heading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* TP Form Errors Heading.
*
* Displays the error count. User controls the heading element wrapper.
*/
export class TPFormErrorsHeadingElement extends HTMLElement {
/**
* Get format.
*
* @return {string} Format with $count placeholder.
*/
get format(): string {
// Get format.
return this.getAttribute( 'format' ) ?? '';
}

/**
* Set format.
*
* @param {string} format Format string.
*/
set format( format: string ) {
// Set format.
this.setAttribute( 'format', format );
}

/**
* Update the heading with the error count.
*
* @param {number} count Number of errors.
*/
update( count: number ): void {
// Update count.
this.textContent = this.format.replace( '$count', count.toString() );
}
}
Loading