Forms¶
Business applications are mostly forms, so this is the part worth getting right.
Use the macros¶
{% from "ads/form.html" import field, select, textarea, checkbox, button %}
<form method="post" class="ads-stack">
{{ field("email", label="Email", type="email", required=true,
help="We only use this for sign-in.",
errors=form.errors.get("email")) }}
{{ textarea("bio", label="Bio", rows=6, value=user.bio) }}
{{ select("role", label="Role", value=user.role,
options=[("admin", "Administrator"), ("editor", "Editor")]) }}
{{ checkbox("digest", label="Send me the weekly digest", checked=user.digest) }}
<div class="ads-cluster">
{{ button("Save", type="submit") }}
{{ button("Cancel", variant="secondary") }}
</div>
</form>
What the macros do that hand-written markup usually forgets:
aria-describedbywired to both the help text and the error, in that orderaria-invalid="true"when there are errorsrequiredon the control, not only an asterisk on the label- a
<label for>that matches, or an implicit label for the checkbox
There is no .is-invalid¶
The invalid state keys off aria-invalid="true" in form.css — the attribute the field has to
carry anyway:
So a field cannot look wrong without announcing that it is wrong, and the two cannot drift
apart. The same principle covers aria-current for navigation and aria-pressed for toggles.
Hand-writing a field¶
When you need something the macros do not cover:
<div class="ads-field">
<label class="ads-label" for="q">Search</label>
<div class="ads-input-icon">
<span class="ads-input-icon__addon">{{ icon("search") }}</span>
<input class="ads-input" id="q" name="q" type="search"
aria-describedby="q-help">
</div>
<span class="ads-help" id="q-help">Try a company name.</span>
</div>
The control vocabulary¶
| class | what it is |
|---|---|
ads-input |
text, email, number, search, date… |
ads-select |
a <select>, with the chevron drawn by CSS (RTL-aware) |
ads-textarea |
multi-line |
ads-check |
the label wrapping a checkbox or radio |
ads-switch |
a checkbox for a setting that applies immediately |
ads-choice |
a radio or checkbox presented as a card |
ads-range |
a slider |
ads-chips--input |
a tag input: real <input>, chips beside it |
ads-field |
the wrapper: label, control, help, error |
ads-label--required |
adds the asterisk (decorative, aria-hidden) |
Checkboxes and radios are drawn (appearance: none, a 20px box, the tick as an SVG
background) so they can be sized with the system and match the reference. The rule is keyed on
the control, not on a wrapper class — a wrapper is a promise every author has to remember, and
three did not, which is how three native 13px radios shipped next to our 20px drawn ones.
Buttons¶
<button class="ads-btn ads-btn--primary">Save</button>
<button class="ads-btn">Cancel</button>
<button class="ads-btn ads-btn--ghost">Dismiss</button>
<button class="ads-btn ads-btn--danger">Delete</button>
<button class="ads-btn ads-btn--sm">Small</button>
A toggle button carries its state as an attribute:
Target size¶
Anything a person has to hit is at least --ads-tap (24 CSS pixels), or has 24 pixels of clear
space around it. If you build a control of your own, that is the floor — and the browser sweep
enforces it across every route, applying WCAG 2.5.8's exceptions rather than skipping them: a
control inside its own <label> is hit by hitting the label, and a target with nothing near it
passes on spacing.