Validation
tier 2 — classes
The error border keys off aria-invalid, never a class, so the styling and what a
screen reader announces cannot disagree. Point aria-describedby at the message and it
is read as part of the field.
Error and warning
.form-error is a rejected value. .form-warning is one that is
accepted but unwise — it carries no aria-invalid, because the field is
valid.
Not a complete address — nothing would be delivered.
Beyond the 365-day archive window, so the tail cannot be restored.
<div style="max-width:460px; display:flex; flex-direction:column; gap:16px">
<div class="form-field">
<label class="form-label form-label--required" for="f-mail">Notification address</label>
<input class="form-input" id="f-mail" type="email" required
aria-invalid="true" aria-describedby="f-mail-err" value="ops.example" />
<p class="form-error" id="f-mail-err">
<i class="ri-error-warning-line"></i>
<span>Not a complete address — nothing would be delivered.</span>
</p>
</div>
<div class="form-field">
<label class="form-label" for="f-retain">Retention</label>
<input class="form-input" id="f-retain" type="text" value="400 days"
aria-describedby="f-retain-warn" />
<p class="form-warning" id="f-retain-warn">
<i class="ri-alert-line"></i>
<span>Beyond the 365-day archive window, so the tail cannot be restored.</span>
</p>
</div>
</div>
Across every control
One attribute, the same result on an input, a select and a text area. The select is the case
worth knowing: it needs an empty first option for required to bite at all.
Not a complete address — nothing would be delivered.
Pick a team, or nobody is paged.
An override is recorded against your name, so it needs a reason.
<div class="sedna-col" style="max-width:520px; gap:16px">
<div class="form-field">
<label class="form-label form-label--required" for="av-mail">Notification address</label>
<input class="form-input" id="av-mail" type="email" required
aria-invalid="true" aria-describedby="av-mail-err" value="ops.example" />
<p class="form-error" id="av-mail-err">
<i class="ri-error-warning-line"></i>
<span>Not a complete address — nothing would be delivered.</span>
</p>
</div>
<div class="form-field">
<label class="form-label form-label--required" for="av-team">Escalation team</label>
<select class="form-input form-select" id="av-team" required
aria-invalid="true" aria-describedby="av-team-err">
<option value="" selected>Choose a team…</option>
<option value="fulfilment">Fulfilment</option>
<option value="network">Network</option>
</select>
<p class="form-error" id="av-team-err">
<i class="ri-error-warning-line"></i>
<span>Pick a team, or nobody is paged.</span>
</p>
</div>
<div class="form-field">
<label class="form-label form-label--required" for="av-reason">Reason</label>
<textarea class="form-input" id="av-reason" rows="2" required
aria-invalid="true" aria-describedby="av-reason-err"></textarea>
<p class="form-error" id="av-reason-err">
<i class="ri-error-warning-line"></i>
<span>An override is recorded against your name, so it needs a reason.</span>
</p>
</div>
</div>
Marking a field required
.form-label--required draws the marker and required on the control
does the enforcing. Write both: the marker is decoration, and a screen reader is told by the
attribute.
Marked and enforced: the label carries the marker, the control carries required.
No marker, no attribute. The two always agree.
<div class="sedna-col" style="max-width:460px; gap:16px">
<div class="form-field">
<label class="form-label form-label--required" for="rq-host">Hostname</label>
<input class="form-input" id="rq-host" type="text" required value="exch-02.example.com" />
<p class="form-hint">Marked and enforced: the label carries the marker, the control carries <code>required</code>.</p>
</div>
<div class="form-field">
<label class="form-label" for="rq-owner">Owner</label>
<input class="form-input" id="rq-owner" type="text" placeholder="Optional" />
<p class="form-hint">No marker, no attribute. The two always agree.</p>
</div>
</div>
Inside a two-up row
The message belongs to the field, so it sits under that field's control and not under the
row; the .sedna-grid keeps the two columns aligned at the
top. A form that rejected more than one field also says so once, at the top, in a
validation summary linking to each.
<form style="max-width:560px">
<div class="sedna-grid sedna-grid--sm sedna-grid--fit">
<div class="form-field">
<label class="form-label form-label--required" for="vg-host">Host</label>
<input class="form-input" id="vg-host" type="text" value="src-db-14" required />
</div>
<div class="form-field">
<label class="form-label form-label--required" for="vg-port">Port</label>
<input class="form-input" id="vg-port" type="number" value="65900" required
aria-invalid="true" aria-describedby="vg-port-err" />
<p class="form-error" id="vg-port-err">
<i class="ri-error-warning-line"></i>
<span>Above 65535, so no port can answer.</span>
</p>
</div>
<div class="form-field sedna-span-full">
<label class="form-label" for="vg-note">Note</label>
<textarea class="form-input" id="vg-note" rows="2" aria-describedby="vg-note-warn">Temporary — remove after the migration</textarea>
<p class="form-warning" id="vg-note-warn">
<i class="ri-alert-line"></i>
<span>Notes are shown to every operator, including contractors.</span>
</p>
</div>
</div>
<div class="form-actions">
<button class="btn" type="button">Cancel</button>
<button class="btn btn-primary" type="button">Save connection</button>
</div>
</form>