The invalid state
A control with aria-invalid="true" gets the danger border. The same border
applies on its own through :user-invalid: the state matches once the user
has interacted with a control and left it invalid, and it clears when the value passes.
An empty required field does not render invalid on page load, because nothing has been
interacted with yet.
Note that this automatic state doesn't require any scripting, but it also doesn't
provide a message. So, browsers without :user-invalid support keep the
aria-invalid border, but they ignore the automatic one.
Marking on blur
When a field needs its own message before you submit, you can set the state yourself
using on blur after the field has been used. For example, set
aria-invalid="true", show the message, and point
aria-describedby at the message. On a later blur that passes, undo all
three. That demo below provides a sample JavaScript snippet that does this.
<form class="stack" style="max-inline-size: 24rem" data-validate>
<div>
<div class="stack is-tight">
<label for="val-email">Email</label>
<input id="val-email" type="email" class="input" required />
<p class="text-danger text--1" hidden>
Enter an address like name@example.com.
</p>
</div>
</div>
<button class="button" type="submit">Subscribe</button>
</form>
<script>
document.querySelector('form[data-validate]').addEventListener(
'blur',
(e) => {
const control = e.target;
if (!control.matches('.input')) return;
const message = control.closest('.stack').querySelector('.text-danger');
const invalid = control.value !== '' && !control.checkValidity();
/* aria-invalid carries the literal value "true" -- the
empty value toggleAttribute writes never matches the
[aria-invalid="true"] arm of the invalid restyle. */
if (invalid) control.setAttribute('aria-invalid', 'true');
else control.removeAttribute('aria-invalid');
message.toggleAttribute('hidden', !invalid);
},
true
);
</script>
On submit
The native submit pass blocks required and format violations, focuses the first invalid
control, and shows a message next to it. That works for most forms. But, a custom
handler can help mark each failing field the same way as the blur pass: the attribute,
the message,
aria-describedby. The styling does not change.
While submitting
While the form is submitting, it should not be possible to submit again, and the wait
should be visible. To show this state, you can make two changes: disable the
fieldset wrapping the fields, and set aria-busy="true" on the
submit button, which shows Brio's spinner. Disabling the fieldset disables every control
inside it at once; no script walks the fields. Undo both when the response arrives. Or,
if you're not using a fieldset, disable each control individually.
<form class="stack" style="max-inline-size: 24rem" data-submit-demo>
<fieldset class="stack is-tight">
<div class="stack is-tight">
<label for="sub-team">Team name</label>
<input id="sub-team" type="text" class="input" required />
</div>
<div class="stack is-tight">
<label for="sub-seats">Seats</label>
<input id="sub-seats" type="number" class="input" value="5" min="1" required />
</div>
</fieldset>
<button class="button" type="submit">Create team</button>
</form>
<script>
document.querySelector('form[data-submit-demo]').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.currentTarget;
const button = form.querySelector('button[type="submit"]');
form.querySelector('fieldset').disabled = true;
button.setAttribute('aria-busy', 'true');
setTimeout(() => {
form.querySelector('fieldset').disabled = false;
button.removeAttribute('aria-busy');
}, 1500);
});
</script>
Server errors
When a server rejects or invalidates a submission, you can return a page or snippet with
the form intact and an alert above it. Use .alert.is-danger with
role="alert", to help announce it to screen readers.
2 problems need fixing
<div class="stack" style="max-inline-size: 24rem">
<div class="alert media is-danger" role="alert">
<svg
class="media-figure icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<div class="media-body">
<h4>2 problems need fixing</h4>
<ul>
<li><a href="#srv-team">The team name is already taken.</a></li>
<li><a href="#srv-seats">Seats must be a whole number.</a></li>
</ul>
</div>
</div>
</div>
For the fields, you can add an error message to each field, which helps explain what
went wrong. If you're using a fieldset, you can add an error message to it, too. Each
field the summary names is a field unit in its error state:
aria-invalid="true", the message in text-danger,
aria-describedby pointing at it. So, the links in the alert can be set to
match the field's id, and Brio's reset adds scroll margin to
:target so following it clears the viewport edge.
Client-side validation still runs on a form like this. The client catches format problems before submission; the server's response re-renders the form, and the re-rendered markup is what shows.
<form class="stack" style="max-inline-size: 24rem">
<div>
<div class="stack is-tight">
<label for="srv-team">Team name</label>
<input
id="srv-team"
type="text"
class="input"
required
aria-invalid="true"
aria-describedby="srv-team-error"
/>
<p id="srv-team-error" class="text-danger text--1">
The team name is already taken. Try one with a suffix.
</p>
</div>
</div>
<div>
<div class="stack is-tight">
<label for="srv-seats">Seats</label>
<input
id="srv-seats"
type="number"
class="input"
value="2.5"
required
aria-invalid="true"
aria-describedby="srv-seats-error"
/>
<p id="srv-seats-error" class="text-danger text--1">
Seats must be a whole number.
</p>
</div>
</div>
<div class="cluster is-end">
<button class="button" type="submit">Create team</button>
</div>
</form>