Casilla, opción e interruptor
Los tres son el <input> nativo con otro aspecto, y nada más. El
name, el value, el required y el anuncio del lector de pantalla siguen
siendo los del navegador — el formulario envía como siempre, y no hay JavaScript alguno.
Ejemplos
Todo lo de abajo es el mismo <input> con clases y atributos distintos.
Estado mixto
El indeterminate nativo se convierte en un guion. No existe como atributo de HTML — solo como propiedad, en JavaScript.
Lado a lado
is-inline en el grupo pone las opciones en la misma línea, saltando de línea cuando no caben.
Con error
aria-invalid="true" en el input pinta el borde mientras está desmarcado — Django 5 ya escribe el atributo.
Acepta los términos para continuar.
Control a la derecha
is-end hace clicable toda la fila, con los controles alineados en una columna — la lista de preferencias.
Cómo usar
Una clase en el input y un <label class="tuc-choice"> alrededor. Hacer clic en el texto marca, y el
lector de pantalla lee el texto como nombre del control — sin for ni id.
<label class="tuc-choice">
<input type="checkbox" class="tuc-check" name="invoice"> Factura por correo
</label>
<fieldset class="tuc-choices">
<legend>Envío</legend>
<label class="tuc-choice">
<input type="radio" class="tuc-radio" name="shipping" value="standard" checked>
<span>Normal <span class="tuc-choice__hint">Hasta 7 días hábiles</span></span>
</label>
<label class="tuc-choice">
<input type="radio" class="tuc-radio" name="shipping" value="express">
<span>Exprés <span class="tuc-choice__hint">Llega mañana</span></span>
</label>
</fieldset>
<label class="tuc-choice is-end">
<input type="checkbox" role="switch" class="tuc-switch" name="notifications"> Avisos por correo
</label>Con descripción, el texto y .tuc-choice__hint van dentro de un <span>: es lo que mantiene
los dos apilados junto al control, alineado con la primera línea.
En el formulario de Django
La clase va en los attrs del widget. Para opciones (RadioSelect,
CheckboxSelectMultiple), recorre el campo en el template: cada ítem entrega el input listo en
tag y el texto en choice_label.
class PreferencesForm(forms.Form):
invoice = forms.BooleanField(
required=False, label="Factura por correo",
widget=forms.CheckboxInput(attrs={"class": "tuc-check"}),
)
notifications = forms.BooleanField(
required=False, label="Avisos por correo",
widget=forms.CheckboxInput(attrs={"class": "tuc-switch", "role": "switch"}),
)
shipping = forms.ChoiceField(
choices=[("standard", "Normal"), ("express", "Exprés")],
widget=forms.RadioSelect(attrs={"class": "tuc-radio"}),
)<label class="tuc-choice">{{ form.invoice }} {{ form.invoice.label }}</label>
<label class="tuc-choice is-end">{{ form.notifications }} <span>{{ form.notifications.label }}</span></label>
<fieldset class="tuc-choices">
<legend>{{ form.shipping.label }}</legend>
{% for option in form.shipping %}
<label class="tuc-choice">{{ option.tag }} {{ option.choice_label }}</label>
{% endfor %}
</fieldset>El POST es el de HTML: una casilla marcada envía su value (u on, si no tiene), una desmarcada no envía
nada — por eso el BooleanField necesita required=False.
Estado mixto
Para la casilla que resume un grupo, como el "seleccionar todo" de una tabla. Solo existe como propiedad:
document.querySelector('#all').indeterminate = true;El interruptor es para lo que ocurre al instante
La casilla, para lo que solo vale al guardar. Un interruptor en un formulario que todavía pide "Guardar" promete un efecto que no ocurrió.
Por qué dibujados
accent-color lo resolvería en una línea, pero entrega el aspecto del sistema: en macOS un azul que
no es el tuyo, en Windows otras esquinas, y ninguno con el radio del resto de los campos.
Y solo pinta la casilla marcada — la opción de al lado seguiría con el círculo del sistema. Dibujados, los tres
combinan entre sí y con el input vecino en cualquier sistema, y usan los mismos tokens: el acento es
--tuc-accent, el anillo de foco es el --tuc-accent-ring de los campos.
Resiste el CSS del proyecto
Las clases se repiten en el selector (input.tuc-check.tuc-check) para ganarle a reglas comunes como input[type=checkbox] { ... } y .card label { display: block }, que con una sola clase desarmarían la fila.
Teclado y accesibilidad
Todo es del navegador, porque el control sigue siendo el input. El <fieldset> con
<legend> le da al lector de pantalla el nombre del grupo de opciones.
| Tecla | Acción |
|---|---|
Tab | Llega a la casilla o al interruptor; en un grupo de opciones, a la marcada |
Espacio | Marca y desmarca la casilla, enciende y apaga el interruptor |
↑ ↓ ← → | Recorre las opciones con el mismo name, marcándolas |
El interruptor necesita role="switch" en el template
El CSS no asigna roles. Es el role lo que hace que el lector de pantalla diga "interruptor, activado" en lugar de "casilla de verificación, marcada" — y quien lo olvide sigue teniendo un checkbox que funciona, solo anunciado con el nombre equivocado.
Con prefers-reduced-motion se desactivan las transiciones al marcar y al deslizar el interruptor.
Clases
Solo CSS. No hay componente que inicializar ni evento propio: escucha el change nativo.
| Clase o atributo | Dónde | Para qué |
|---|---|---|
tuc-check | input type="checkbox" | Casilla de verificación; el estado mixto es el indeterminate nativo |
tuc-radio | input type="radio" | Opción — una de varias con el mismo name |
tuc-switch | input type="checkbox" | Interruptor; exige role="switch" en el input |
tuc-choice | label | Une control y texto, con el control alineado a la primera línea |
tuc-choice__hint | span dentro del texto | Descripción debajo del texto |
is-end | .tuc-choice | Control a la derecha y toda la fila clicable |
tuc-choices | fieldset o div | Grupo apilado; quita el borde, el padding y el min-width del fieldset |
is-inline | .tuc-choices | Opciones lado a lado, saltando de línea |
disabled | input | Atenúa el control y el texto de la etiqueta, con cursor de bloqueo |
aria-invalid="true" | input | Borde de error mientras está desmarcado; :user-invalid hace lo mismo después de que la persona interactúa |