Caixa, opção e chave
Os três são o <input> nativo com o desenho trocado, e nada mais. O
name, o value, o required e o anúncio do leitor de tela continuam
sendo os do navegador — o formulário posta como sempre postou, e não há JavaScript nenhum.
Exemplos
Tudo abaixo é o mesmo <input> com classes e atributos diferentes.
Estado misto
O indeterminate nativo vira um traço. Ele não existe como atributo de HTML — só como propriedade, em JavaScript.
Lado a lado
is-inline no grupo põe as opções na mesma linha, quebrando quando não cabem.
Com erro
aria-invalid="true" no input pinta a borda enquanto ele está desmarcado — o Django 5 já escreve o atributo.
Aceite os termos para continuar.
Controle à direita
is-end faz a linha inteira clicável, com os controles alinhados numa coluna — a lista de preferências.
Como usar
Classe no input e um <label class="tuc-choice"> em volta. Clicar no texto marca, e o
leitor de tela lê o texto como nome do controle — sem for nem id.
<label class="tuc-choice">
<input type="checkbox" class="tuc-check" name="invoice"> Nota fiscal por e-mail
</label>
<fieldset class="tuc-choices">
<legend>Entrega</legend>
<label class="tuc-choice">
<input type="radio" class="tuc-radio" name="shipping" value="standard" checked>
<span>Normal <span class="tuc-choice__hint">Até 7 dias úteis</span></span>
</label>
<label class="tuc-choice">
<input type="radio" class="tuc-radio" name="shipping" value="express">
<span>Expressa <span class="tuc-choice__hint">Chega amanhã</span></span>
</label>
</fieldset>
<label class="tuc-choice is-end">
<input type="checkbox" role="switch" class="tuc-switch" name="notifications"> Avisos por e-mail
</label>Com descrição, texto e .tuc-choice__hint vão dentro de um <span>: é o que mantém
os dois empilhados ao lado do controle, alinhado à primeira linha.
No formulário do Django
A classe vai nos attrs do widget. Para opções (RadioSelect,
CheckboxSelectMultiple), percorra o campo no template: cada item entrega o input pronto em
tag e o texto em choice_label.
class PreferencesForm(forms.Form):
invoice = forms.BooleanField(
required=False, label="Nota fiscal por e-mail",
widget=forms.CheckboxInput(attrs={"class": "tuc-check"}),
)
notifications = forms.BooleanField(
required=False, label="Avisos por e-mail",
widget=forms.CheckboxInput(attrs={"class": "tuc-switch", "role": "switch"}),
)
shipping = forms.ChoiceField(
choices=[("standard", "Normal"), ("express", "Expressa")],
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>O POST é o do HTML: caixa marcada envia o value (ou on, sem ele), desmarcada não envia
nada — é por isso que o BooleanField precisa de required=False.
Estado misto
Para a caixa que resume um grupo, como o "selecionar todos" de uma tabela. Só existe como propriedade:
document.querySelector('#all').indeterminate = true;Chave é para o que acontece na hora
Caixa, para o que só vale ao salvar. Uma chave num formulário que ainda pede "Salvar" promete um efeito que não aconteceu.
Por que desenhados
accent-color resolveria em uma linha, mas entrega o desenho do sistema: no macOS um azul que
não é o seu, no Windows outro canto, e nenhum dos dois com o raio do resto dos campos.
E ele só pinta a caixa marcada — a opção ao lado continuaria com o círculo do sistema. Desenhados, os três
combinam entre si e com o input vizinho em qualquer sistema, e usam os mesmos tokens: o destaque é
--tuc-accent, o anel de foco é o --tuc-accent-ring dos campos.
Resiste ao CSS do projeto
As classes são repetidas no seletor (input.tuc-check.tuc-check) para vencer regras comuns como input[type=checkbox] { ... } e .card label { display: block }, que com uma classe só desmontariam a linha.
Teclado e acessibilidade
Tudo é do navegador, porque o controle continua sendo o input. O <fieldset> com
<legend> dá ao leitor de tela o nome do grupo de opções.
| Tecla | Ação |
|---|---|
Tab | Chega na caixa ou na chave; num grupo de opções, na que está marcada |
Espaço | Marca e desmarca a caixa, liga e desliga a chave |
↑ ↓ ← → | Anda entre as opções de mesmo name, marcando |
A chave precisa de role="switch" no template
CSS não põe papel. É o role que faz o leitor de tela dizer "chave, ligada" em vez de "caixa de seleção, marcada" — e quem esquecer continua com um checkbox que funciona, só anunciado pelo nome errado.
Com prefers-reduced-motion as transições de marcar e de deslizar a chave são desligadas.
Classes
Só CSS. Não há componente para inicializar nem evento próprio: ouça o change nativo.
| Classe ou atributo | Onde | Para quê |
|---|---|---|
tuc-check | input type="checkbox" | Caixa de seleção; o estado misto é o indeterminate nativo |
tuc-radio | input type="radio" | Opção — uma de várias com o mesmo name |
tuc-switch | input type="checkbox" | Chave; exige role="switch" no input |
tuc-choice | label | Junta controle e texto, com o controle alinhado à primeira linha |
tuc-choice__hint | span dentro do texto | Descrição embaixo do texto |
is-end | .tuc-choice | Controle à direita e a linha inteira clicável |
tuc-choices | fieldset ou div | Grupo empilhado; desfaz a borda, o padding e o min-width do fieldset |
is-inline | .tuc-choices | Opções lado a lado, quebrando linha |
disabled | input | Esmaece o controle e o texto do rótulo, com cursor de bloqueio |
aria-invalid="true" | input | Borda de erro enquanto desmarcado; :user-invalid faz o mesmo depois de a pessoa mexer |