Ir ao conteúdo
SEED engenhariaDesign System

Componentes

3. Input de texto

estávelseed-componentes.md v1.43 · §03seção 5 de 9803-input-de-texto-estavel-validado-pelo-rafael-em.md · MD5 0620d375

Tí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#

  1. 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.
  2. 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)#

  1. 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. ✅

BancadaSEED DS v2 — Bloco 2 · Preview v0.13 (13/13)abrir em página própria ↗

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.

Esc