Tucano v0.37.2

Form

Label, help text, error message and the text field, with classes only and no JavaScript. The field that failed is marked by aria-invalid="true", the attribute the screen reader announces and that Django 5 writes on its own — so {{ field }} comes out red without anyone writing a thing.

As it appears on the registration certificate.

Enter a complete email address.

You can change it later, with no fee.

Examples

The same four classes in different situations.

Required

is-required on the label adds the asterisk; required goes on the field.

Browser validation

Painted by :user-invalid, only after the field is touched. Type something without @ and leave.

With an icon

.tuc-input-group with an SVG before the field.

Disabled

Native disabled; nothing else.

Short list

data-tuc-select with data-search="false": no search box, and with the aria-invalid error.

Choose a state.

Multiple choices

With multiple the choices become tags, and getlist() still receives one per option.

The error works on the components

The same aria-invalid="true" paints the fields the script replaces with its own control. The attribute stays on the native element, and the CSS reaches the control from there.

Choose the customer.

The date must be after today.

Invalid CNPJ.

This color is already in use.

Required to continue.

How to use

A label with for, and help and error tied to the field by aria-describedby. That is what makes the screen reader read the help and the error along with the field name.

<label class="tuc-label is-required" for="doc">CNPJ</label>
<input id="doc" name="doc" data-tuc-mask="cnpj" required aria-invalid="true" aria-describedby="doc-e">
<p class="tuc-error" id="doc-e">Invalid CNPJ.</p>

<label class="tuc-label" for="plan">Plan</label>
<select id="plan" name="plan" data-tuc-select aria-describedby="plan-h">
  <option>Monthly</option>
  <option>Yearly</option>
</select>
<p class="tuc-hint" id="plan-h">You can change it later.</p>

<textarea class="tuc-input" name="notes" rows="3"></textarea>

The field components — date picker, mask, color picker — put on .tuc-input by themselves. Write the class on plain fields, so the whole form row has the same height.

In the Django template

{# Django 5 already puts aria-invalid and aria-describedby on the field #}
<label class="tuc-label{% if field.field.required %} is-required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<p class="tuc-hint" id="{{ field.auto_id }}_helptext">{{ field.help_text }}</p>{% endif %}
{% for error in field.errors %}<p class="tuc-error">{{ error }}</p>{% endfor %}

The help id is the one Django 5 points to in the field's aria-describedby, <field id>_helptext. With this snippet in an {% include %}, every field in the project gets the same label, help and error.

In the Django form

class CustomerForm(forms.ModelForm):
    class Meta:
        model = Customer
        fields = ["company", "email", "plan", "notes"]
        widgets = {
            "company": forms.TextInput(attrs={"class": "tuc-input"}),
            "email": forms.EmailInput(attrs={"class": "tuc-input"}),
            "plan": forms.Select(attrs={"data-tuc-select": ""}),
            "notes": forms.Textarea(attrs={"class": "tuc-input", "rows": 3}),
        }

In JavaScript

There is no component: to flag an error only the client finds, write the attribute, and remove it once it is fixed.

const field = document.querySelector('#email');
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby', 'email-e');
// fixed:
field.removeAttribute('aria-invalid');

Why an attribute, and not a class

A red class only serves those who can see it. aria-invalid serves the screen and the screen reader at the same time, which is why the two cannot drift apart.

Since Django 5.0 the form writes the attribute on every field that came back with an error. On components that replace the field with their own control — select, color picker, editor, upload — the attribute stays on the native element, and the CSS reaches the control through select[aria-invalid] + .tuc-select and :has(). No component copies the attribute, so none of them falls out of sync when HTMX swaps the field. Even before the script mounts, the raw field already starts with a red border.

Browser validation paints, but only after the field is touched

An empty required field or a type="email" without @ turn red through :user-invalid when the person leaves it or tries to submit — and not on page load, which would start out all red. A browser that doesn't know the selector simply doesn't paint this case.

Green when it passes, only where you asked

With data-validate on a native .tuc-input — email, required, pattern —, the field turns green through :user-valid once the person fills it in correctly. Without the attribute nothing changes: a whole form going green is noise. On a mask with data-validate the green shows while typing. Errors win: a field with aria-invalid="true" never turns green.

On focus the ring is red too, and doesn't go back to the accent color: the error still holds while it is being fixed. On the checkbox and the radio the error shows only while they are unchecked — once checked, the accent color says more than an error that no longer applies. The is-required asterisk is decoration only: the screen reader already announces required, and hearing "asterisk" after the name would get in the way.

Lives alongside the project's CSS

label and input[type=text] are among the tags every project styles, and .card label would beat a single class. The classes here repeat in the selector to weigh more, so the label and the field don't fall apart inside the cards of your layout.

Classes

CSS only. None of them needs JavaScript.

Class or attributeWhereWhat for
tuc-label<label>Label above the field
is-requiredwith tuc-labelAsterisk after the name, hidden from the screen reader
tuc-hintbelow the fieldHelp text
tuc-errorbelow the fieldError message
tuc-input<input> <textarea> <select>Field with the library's height, border and focus; on <select>, the arrow
tuc-input-grouparound the fieldMakes room for an <svg> on the left
aria-invalid="true"on the native fieldError border and ring, on any field in the library
:user-invalidautomaticBrowser validation error, after the person touches the field
tuc-invalid<input>Set by the mask when validation fails, together with aria-invalid; don't write it by hand