Componentes
3. Input de texto
seed-componentes.md v1.43 · §03seção 5 de 9803-input-de-texto-estavel-validado-pelo-rafael-em.md · MD5 0620d375Título completo no canon: Input de texto — estável · validado pelo Rafael em 2026-08-02 (v0.7)
Primeiro consumidor concreto do form-field (§2): herda integralmente moldura, 9 estados, tokens, contrato HTML, máscaras (§2.7), microcopy (§2.8) e a11y (§2.9) — esta seção documenta APENAS o que é do input: os slots internos do controle, as decisões C1–C4, a subvariante senha (normativa), a escala de larguras e os utilitários prometidos na §2.12 (hook de validação B4 + registry de máscaras de referência).
Base de evidência: 3 rodadas próprias (2026-08-02, somadas às 4 do form-field): Carbon, GOV.UK, USWDS, GC Canada, Vaadin, Designsystemet (NO), M3, NN/g, WebAIM/ONS/Scott O'Hara (rodada 1) · Ant Design, Salesforce Lightning, SAP Fiori input, Uber Base Web (rodada 2) · WCAG 2.2 SC 3.3.8/3.3.7, NIST SP 800-63B rev.4, Animalia DS (BR), TOTVS PO UI (BR), Apple HIG (rodada 3). Decisões C1–C5 + adições normativas aprovadas pelo Rafael em 2026-08-02 (C5 emendada no §2).
3.1 Papel e fronteiras (quando NÃO usar input)#
Entrada livre de linha única. Escolha vence digitação (Apple HIG): se as respostas possíveis são conhecidas, use um controle de escolha. Régua de volume — ATUALIZADA na v0.11 pela régua unificada da família de escolha (§7.1, decisão G1): 2–6 → radio/checkbox · 7–15 (teto ~20 no ERP denso) → select · acima, ou com busca → combobox · aberto de fato → input com sugestões. (A versão v0.7 desta linha citava só o corte Fiori de ~20; a régua do §7.1 a harmoniza com NN/g, USWDS, M3 e CMS.) Texto longo/multilinha → textarea. Busca → item próprio (padrões de teclado e clear diferentes).
3.2 Anatomia do controle — slots internos#
┌──────────────────────────────────────────────────────┐
│ [ícone] [prefixo] valor digitado [sufixo] [✕] [⟳] │
└──────────────────────────────────────────────────────┘
① ② ③ ④ ⑤ ⑥
| # | Slot | Regras |
|---|---|---|
| ① | Ícone leading 20px | Decorativo/categórico (aria-hidden); nunca portador único de significado |
| ② | Prefixo | Símbolo/texto fixo NÃO editável (R$, +55, https://). Cor --seed-field-help-text |
| ③ | Valor | Herda §2. Dado de medição/identificador: JetBrains Mono, alinhado à direita quando numérico em formulário de edição (SAP Fiori — colunas de valores comparáveis no ERP) |
| ④ | Sufixo | Unidade/domínio (kWh, kW, %, @seed.com.br). Mesma cor do prefixo |
| ⑤ | Clear (✕) | Botão real 32×32px com hit-area estendida à altura do campo (≥24px WCAG 2.5.8, folga); ver C2 |
| ⑥ | Loading | Spinner 16px + aria-busy + texto assistivo "Consultando…" (padrão SLDS); substitui ⑤ durante a operação |
Regra dura de DOM estável (armadilha Ant Design, bug real documentado): os slots existem SEMPRE na árvore (vazios quando inativos) — adicionar/remover prefixo/sufixo/clear dinamicamente recria a estrutura e o input perde o foco no meio da digitação.
Overflow: valor maior que o campo rola horizontalmente (nativo); em desktop, title com o valor completo (tooltip de expansão — Apple HIG). Nunca truncar o dado armazenado.
3.3 Decisões C1–C4 (aprovadas 2026-08-02; C5 → emenda §2)#
| # | Decisão | Porquê (fontes) | Descartado |
|---|---|---|---|
| C1 | Prefixo/sufixo internos para unidade/símbolo; valor contém só o dado | USWDS: unidade nunca dentro do value (quebra canônico e máscara); M3: prefixo=moeda, sufixo=unidade/domínio; não usar afixo em campo de resposta aberta | Unidade digitada no valor; afixo externo ao campo (Fiori — frágil em coluna estreita mobile); afixo como substituto de rótulo (Vaadin proíbe) |
| C2 | Clear como slot opcional, restrito: padrão em busca/filtros; opcional nos demais; NUNCA em dado crítico já validado | Carbon: aparece com conteúdo, tab stop, Esc limpa; WebAIM/WCAG: se existe p/ mouse, DEVE ser operável por teclado; Scott O'Hara: some em readonly/disabled, foco RETORNA ao campo | Clear universal (Ant permite — no ERP vira perda acidental); ✕ só-mouse decorativo (falha WCAG A) |
| C3 | Senha: subvariante normativa — ver §3.5 | WCAG 2.2 SC 3.3.8 (AA) + NIST SP 800-63B: virou norma, não preferência | — |
| C4 | Largura comunica o comprimento esperado: escala xs·sm·md·full, aplicada PELO LAYOUT, consistente POR GRUPO |
Carbon: proporcional ao conteúdo; GC Canada: fixa p/ comprimento conhecido, ≤75 chars; Apple: larguras consistentes por grupo de campos; Fiori: aplicar via layout do form, não hardcode no componente | Tudo full-width (mente sobre o dado; padrão preguiçoso); largura campo-a-campo isolada (poluição visual — Apple) |
Escala de larguras (aplicada via layout): xs 120px (CEP, UC, sigla) · sm 200px (CPF, telefone, data) · md 320px (nome, e-mail) · full (endereço, campo dominante). Em viewport <640px, todas viram full em coluna única (fundamento §0.8).
3.4 Tipos cobertos (herdam o contrato §2.6)#
text · email · tel · password · url + os mascarados do registry §2.7 (CPF, CNPJ, CPF/CNPJ, CEP+ViaCEP, telefones BR/0800/internacional, moeda, data). search e number são itens próprios do bloco.
3.5 Subvariante senha — NORMATIVA (WCAG 2.2 SC 3.3.8 AA + NIST SP 800-63B)#
| Regra | Norma/fonte |
|---|---|
| NUNCA bloquear colar/autofill (senha e código de verificação) | WCAG 3.3.8 (técnica suficiente) + NIST "SHALL NOT prevent paste" — gerenciador de senha é aliado |
autocomplete="current-password" / "new-password" sempre; nunca off em login |
WCAG 3.3.8 (quebrar gerenciador = falha AA) |
Toggle mostrar/ocultar: botão semântico 32px (hit-area = altura do campo), ícone olho, aria-pressed, aria-label "Mostrar senha" |
NN/g (desmascarar apoia memória e conferência); auditoria: 9 em 10 sites usam div/anchor — o erro clássico |
| Mascarado por padrão em QUALQUER superfície (ERP roda em escritório aberto) | NN/g + política SEED |
| Nunca pré-popular campo de senha | Apple HIG |
| Sem "confirmar senha/e-mail" por redigitação | WCAG 3.3.7 Redundant Entry |
| Campo suporta 64+ caracteres, Unicode e espaços | NIST rev.4 |
| Fronteira: política de senha (mínimo 8/15, blocklist de vazadas, sem composição forçada, sem expiração periódica) pertence à feature de autenticação do produto — o componente garante o SUPORTE | NIST SP 800-63B |
3.6 Estados, tokens e a11y — herdados com 3 adições#
Estados: os 9 do §2.4 tal qual. Tokens novos: nenhum de cor — afixos e ícones usam --seed-field-help-text/currentColor (decisão: menos tokens = menos manutenção; se afixo precisar de ênfase própria um dia, nasce token com par e medição). A11y além do §2.9: ① afixos são aria-hidden e a INFORMAÇÃO vai no rótulo — "Consumo médio (kWh)" — porque leitores de tela não anunciam prefixo/sufixo (Designsystemet); ② clear e toggle são botões no tab order, com foco visível e retorno de foco ao campo após limpar; ③ botão interno: 32×32px visível com hit-area estendida à altura do campo (WCAG 2.5.8 pede ≥24 — folga; exceção documentada ao fundamento 44px por ser controle INTERNO de um alvo que já tem 44px).
3.7 Microcopy — 2 regras além do §2.8#
- Prevenção > correção (Animalia DS): se só UM erro é possível no campo, o texto auxiliar já ensina a evitá-lo ("Somente números") — melhor que esperar o erro.
- Afixo repetido no rótulo, sempre: o sufixo visual "kWh" é conforto de leitura; a informação oficial mora no rótulo "(kWh)".
3.8 Código — HTML/CSS (delta sobre §2.11)#
<div class="seed-field" style="max-width:320px"><!-- 320px direto, e nao `var(--seed-input-w-md,320px)`: o token nunca existiu, e exemplo de codigo num canonico e codigo que alguem copia — citar nele um `var()` que nao resolve e ensinar o defeito. Corrigido em 2026-08-26. -->
<label class="seed-field__label" for="consumo">Consumo médio mensal (kWh)</label>
<div class="seed-input">
<span class="seed-input__affix" aria-hidden="true"><!-- prefixo (vazio = DOM estável) --></span>
<input class="seed-field__control seed-input__control seed-input__control--num"
id="consumo" name="consumo" type="text" inputmode="decimal"
autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="next">
<span class="seed-input__affix" aria-hidden="true">kWh</span>
<span class="seed-input__actions"><!-- clear/toggle/loading --></span>
</div>
<p class="seed-field__help">Está na sua fatura de energia.</p>
</div>
.seed-input{display:flex;align-items:center;gap:8px;height:var(--seed-field-height-md);
padding:0 12px;background:var(--seed-field-bg);
border:1px solid var(--seed-field-border);border-radius:var(--seed-field-radius);
transition:border-color var(--seed-dur-productive-fast) var(--seed-ease-productive)}
.seed-input:hover{border-color:var(--seed-field-border-hover)}
.seed-input:focus-within{box-shadow:var(--seed-focus-ring)} /* foco no WRAPPER */
.seed-input .seed-field__control{border:0;height:100%;padding:0;flex:1;min-width:0;
background:transparent;outline:none;box-shadow:none}
.seed-input__affix{color:var(--seed-field-help-text);font-size:14px;flex:none}
.seed-input__affix:empty{display:none}
.seed-input__control--num{font-family:var(--seed-font-mono);text-align:right}
.seed-input__actions{display:flex;align-items:center;flex:none}
.seed-input__btn{display:flex;align-items:center;justify-content:center;width:32px;height:100%;
background:none;border:0;color:var(--seed-field-help-text);cursor:pointer;border-radius:4px}
.seed-input__btn:focus-visible{outline:none;box-shadow:var(--seed-focus-ring)}
.seed-input[data-invalid="true"]{border-color:var(--seed-field-border-error)}
.seed-input[data-warning="true"]{border-color:var(--seed-field-border-warning)}
.seed-input[data-valid="true"]{border-color:var(--seed-field-border-success)}
@media (pointer: coarse){.seed-input .seed-field__control{font-size:var(--seed-fs-field-touch)}}
3.9 Código — React/TypeScript (Input + hook B4 + registry de máscaras)#
/* ============ useFieldValidation — B4: reward early, punish late (Polaris/Konjević) ============ */
export function useFieldValidation(validate: (v: string) => string | null) {
const [error, setError] = React.useState<string | null>(null);
const [touched, setTouched] = React.useState(false);
return {
error, invalid: !!error, valid: touched && !error,
onBlur: (e: React.FocusEvent<HTMLInputElement>) => {
const v = e.target.value;
if (v.trim() !== "") { setTouched(true); setError(validate(v)); }
/* vazio + intocado: só marca no SUBMIT (Polaris) */
},
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (error) setError(validate(e.target.value)); /* punish late: revalida por tecla SÓ em erro */
},
validateNow: (v: string) => { setTouched(true); const r = validate(v); setError(r); return r; },
};
}
/* ============ Registry de máscaras — B5 (implementação de referência) ============ */
type MaskDef = {
format: (digits: string) => string; /* apresentação */
maxDigits: number;
canonical?: (digits: string) => string; /* default: dígitos puros; telefone: E.164 */
};
const maskRegistry = new Map<string, MaskDef>();
export function registerMask(name: string, def: MaskDef) { maskRegistry.set(name, def); }
export function applyMask(name: string, rawValue: string) {
const def = maskRegistry.get(name);
if (!def) throw new Error(`Máscara não registrada: ${name}`);
const digits = rawValue.replace(/\D+/g, "").slice(0, def.maxDigits); /* tolerante na entrada */
return { display: def.format(digits), canonical: def.canonical?.(digits) ?? digits };
}
/* registro inicial — demais máscaras do §2.7 seguem o mesmo molde; telefone usa libphonenumber-js */
registerMask("cpf-cnpj", { maxDigits: 14, format: d => d.length <= 11
? d.replace(/(\d{3})(\d)/, "$1.$2").replace(/(\d{3})(\d)/, "$1.$2").replace(/(\d{3})(\d{1,2})$/, "$1-$2")
: d.replace(/^(\d{2})(\d)/, "$1.$2").replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d)/, ".$1/$2").replace(/(\d{4})(\d{1,2})$/, "$1-$2") });
registerMask("tel-0800", { maxDigits: 11,
format: d => d.replace(/^(\d{4})(\d)/, "$1 $2").replace(/^(\d{4}) (\d{3})(\d{1,4})/, "$1 $2 $3") });
/* Nota de cursor (armadilha BR §2.7): ao formatar programaticamente, reposicionar o caret
relativo aos DÍGITOS antes do cursor, não ao índice bruto da string. */
/* ============ Input — consome o form-field §2.12 ============ */
type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
prefix?: React.ReactNode; suffix?: React.ReactNode;
clearable?: boolean; onClear?: () => void; loading?: boolean; warning?: boolean;
};
export function Input({ prefix, suffix, clearable, onClear, loading, warning, className, ...props }: InputProps) {
const { id, helpId, errId, invalid, valid } = useField();
const ref = React.useRef<HTMLInputElement>(null);
const showClear = clearable && !loading && !props.readOnly && !props.disabled &&
String(props.value ?? "").length > 0;
return (
<div data-invalid={invalid || undefined} data-warning={warning || undefined}
data-valid={valid || undefined}
className={cn("flex h-11 items-center gap-2 rounded-md border px-3",
"bg-[var(--seed-field-bg)] border-[var(--seed-field-border)]",
"hover:border-[var(--seed-field-border-hover)] focus-within:ring-2",
"focus-within:ring-[var(--seed-border-focus)] focus-within:ring-offset-2",
"data-[invalid]:border-[var(--seed-field-border-error)]",
"data-[warning]:border-[var(--seed-field-border-warning)]",
"data-[valid]:border-[var(--seed-field-border-success)]",
"pointer-coarse:text-[16px]", className)}>
{/* slots SEMPRE presentes — DOM estável (Ant) */}
<span aria-hidden className="flex-none text-sm text-[var(--seed-field-help-text)] empty:hidden">{prefix}</span>
<input ref={ref} id={id} aria-invalid={invalid || undefined}
aria-describedby={cn(invalid && errId, helpId) || undefined}
className="h-full min-w-0 flex-1 bg-transparent text-sm text-[var(--seed-field-value)] outline-none placeholder:text-[var(--seed-field-placeholder)]"
{...props} />
<span aria-hidden className="flex-none text-sm text-[var(--seed-field-help-text)] empty:hidden">{suffix}</span>
<span className="flex flex-none items-center">
{showClear && (
<button type="button" aria-label="Limpar campo"
className="flex h-full w-8 items-center justify-center rounded text-[var(--seed-field-help-text)] focus-visible:ring-2 focus-visible:ring-[var(--seed-border-focus)]"
onClick={() => { onClear?.(); ref.current?.focus(); /* foco RETORNA (O'Hara) */ }}>
<X className="size-4" aria-hidden />
</button>
)}
{loading && <Loader2 className="size-4 animate-spin" aria-hidden />}
{loading && <span className="sr-only" aria-live="polite">Consultando…</span>}
</span>
</div>
);
}
/* PasswordInput: Input + toggle normativo (§3.5) — aria-pressed, nunca bloquear paste,
autocomplete current-password/new-password, mascarado por padrão. */
3.10 Decisões de implementação deste item#
| Decisão | Porquê | Descartado |
|---|---|---|
Foco no WRAPPER (focus-within) |
Com slots internos, o anel precisa envolver o CONJUNTO; input interno sem borda própria | Anel só no input interno (anel "dentro" do campo — quebra a leitura de container) |
Slots sempre no DOM (empty:hidden) |
Bug real Ant: recriar estrutura derruba o foco durante a digitação | Renderização condicional dos slots |
| Zero token de cor novo | Afixo/ícone = papel de ajuda já tokenizado; menos manutenção | --seed-input-affix dedicado (duplicaria help-text sem caso de uso divergente) |
| Numérico = mono + direita SÓ em formulário de edição/ERP | Fiori: colunas de valores comparáveis; em formulário público de 1 coluna, esquerda normal | Direita universal (estranho em campo isolado de landing) |
| Clear limpa e NÃO valida | Limpar é intenção de recomeço; erro imediato no campo vazio pune (B4) | Validar no clear (flash de erro) |
3.11 Aplicação dos 7 testes (§1.12)#
- Container 3:1: herda §2 (borda 4.74/4.50; warning 4.82/8.21 — medidos). ✅ 2. Escada de mecanismos: warning ≠ error por cor E ícone (▲ vs ⚠) E comportamento (não bloqueia); clear/loading são mecanismos, não tons. ✅ 3. Grayscale: ▲/⚠/✓ distinguem os três estados de validação sem matiz. ✅ 4. Par: campo com afixo × sem afixo — hierarquia valor>afixo óbvia (13.86 vs 6.55). ✅ 5. Regra do um: uma mensagem por campo; um estado de validação por vez (warning cede ao error). ✅ 6. Estado atual: senha mostrada/oculta legível pelo ícone + aria-pressed. ✅ 7. Polegar (360px, teclado aberto): campo full-width em coluna única, fonte 16px, clear com hit-area da altura do campo, afixos não colapsam (min-width:0 no input) — faixa 360px no preview. ✅
Também cita o §3: banco-dataviz-dg, banco-dataviz-dm, banco-dataviz-tokens, tela-atividade, tela-autenticacao, tela-configuracoes, tela-documento, tela-shell, tela-tabela.