Ir para o conteúdo

Entrada de dados

Controles para coleta de dados do usuário. Todos forward refs para o elemento DOM nativo (compatível com react-hook-form).

O que é esta categoria

Esta página reúne todo o conjunto de controles de formulário do SDK — desde o Input de texto simples até campos especializados como PinInput (OTP), PasswordInput (com medidor de força), RangeSlider (faixa dupla) e Dropzone (arrastar-e-soltar arquivos). Todos compartilham a mesma API de rótulo/erro/tamanho (ver a seção Convenções abaixo) e fazem forward de ref, então plugam direto em react-hook-form sem wrappers extras.

Quando usar: sempre que precisar coletar um valor do usuário. Escolha o controle pelo tipo de dado — texto curto (Input), texto longo (Textarea), uma opção entre poucas (Radio/Select), uma opção entre muitas com busca (Combobox), booleano (Switch/Checkbox), código de verificação (PinInput), número com incremento (StepperInput), etc.

Comece pelas Convenções

Todos os campos aceitam label, helperText, error, required e size da mesma forma. Aprenda essas 5 props uma vez e você sabe usar qualquer campo desta página.

Convenções

  • label (string ou node) — rótulo acima do campo.
  • helperText — texto auxiliar abaixo; substituído por error quando este é setado.
  • error (string) — mensagem de erro; adiciona aria-invalid="true" + borda vermelha.
  • required — adiciona * no label e propaga required no input.
  • size: "sm" | "md" | "lg" — escala de altura/padding/font via tokens density.

Input

Form fields na gallery

Seção form-fields da gallery — rode localmente para interagir.

Texto single-line.

import { Input } from "tempest-react-sdk";
import { Search } from "lucide-react";

<Input label="Email" type="email" placeholder="user@example.com" required />;
<Input label="Buscar" leftIcon={<Search size={16} />} placeholder="palavra-chave" />;
<Input label="Senha" type="password" error="Mínimo 8 caracteres" />;
Prop Tipo Default
label string —
helperText string —
error string —
leftIcon ReactNode —
rightIcon ReactNode —
size "sm" \| "md" \| "lg" "md"
wrapperClassName string —
... Todos os atributos de HTMLInputElement exceto size —

Textarea

Multi-linha. Mesma API do Input (sem leftIcon/rightIcon).

import { Textarea } from "tempest-react-sdk";

export function Mensagem() {
    return <Textarea label="Mensagem" rows={4} helperText="Máximo 500 caracteres" />;
}

Select

Nativo <select>. Aceita options (lista) ou <option> children. Três variantes: field (default — rótulo, controle, helper e slot de erro), chip (só o controle, para linha de configurações) e bare (o mecanismo sem a vestimenta, para app com identidade própria).

import { Select } from "tempest-react-sdk";

export function Estado() {
    return (
        <Select
            label="UF"
            options={[
                { value: "SP", label: "São Paulo" },
                { value: "RJ", label: "Rio de Janeiro" },
            ]}
        />
    );
}
Prop Tipo Default
options SelectOption[] —
label string —
helperText string —
error string —
variant "field" \| "chip" \| "bare" "field"
caretIcon ReactNode chevron do SDK
wrapperClassName string —

Variante chip — linha de configurações

Numa linha de configurações — ícone e rótulo à esquerda, controle compacto à direita — o campo completo empilha dois campos rotulados dentro do mesmo item de lista. variant="chip" renderiza só o controle, dimensionado pelo conteúdo:

import { ListTile, Select } from "tempest-react-sdk";

export function LinhaIdioma({ lang, setLang }: { lang: string; setLang: (v: string) => void }) {
    return (
        <ListTile
            leading={<span>🌐</span>}
            title="Idioma"
            trailing={
                <Select
                    variant="chip"
                    aria-label="Idioma"
                    value={lang}
                    onChange={(e) => setLang(e.target.value)}
                    options={[
                        { value: "pt", label: "Português" },
                        { value: "en", label: "English" },
                    ]}
                />
            }
        />
    );
}

aria-label é obrigatório no chip — e o tipo cobra

A variante remove o único rótulo visível que o componente tinha. O texto da linha (title do ListTile) nomeia a linha, não o <select> — não há relação de acessibilidade entre os dois —, então sem aria-label o leitor de tela anuncia uma combobox sem nome. SelectChipProps declara "aria-label": string como obrigatório, então esquecê-lo é erro de compilação, não achado de auditoria.

É por isso que não vale escrever um <select> na mão

A saída que todo app tomava era um <select> nativo com appearance: none só para manter o formato de chip. Isso duplica o markup deste componente e perde junto o anel de foco por token, o estado desabilitado e o caret — tudo o que já estava resolvido aqui.

A variante field continua idêntica: label, helperText e error só existem nela.

Opções a partir de um mapa — toOptions, withAllOption, withEmptyOption

O rótulo em pt-BR de um enum da API é quase sempre um mapa valor → rótulo. toOptions converte esse mapa na lista que Select, MultiSelect e Combobox pedem, e duas funções põem na frente as entradas que um <select> não tem como representar sozinho:

import { useState } from "react";
import { ALL_OPTION_VALUE, Select, toOptions, withAllOption, withEmptyOption } from "tempest-react-sdk";

const STATUS = { active: "Ativo", paused: "Pausado", archived: "Arquivado" };
const HUMOR = new Map([
    [5, "Ótimo"],
    [3, "Ok"],
    [1, "Ruim"],
]);

export function Filtros() {
    const [status, setStatus] = useState(ALL_OPTION_VALUE);
    const [humor, setHumor] = useState("");
    const filtro = status === ALL_OPTION_VALUE ? {} : { status };

    return (
        <>
            <Select
                label="Status"
                value={status}
                onChange={(e) => setStatus(e.target.value)}
                options={withAllOption(toOptions(STATUS))}
            />
            <Select
                label="Humor"
                value={humor}
                onChange={(e) => setHumor(e.target.value)}
                options={withEmptyOption(toOptions(HUMOR))}
            />
            <pre>{JSON.stringify(filtro)}</pre>
        </>
    );
}
  • withAllOption(options, label = "Todos") — a entrada "all" ("não filtra"). Ela precisa ser uma opção de verdade, não o placeholder: placeholder lê como "nada escolhido", e ninguém sabe se a coluna está filtrada. Compare com ALL_OPTION_VALUE, não com a string.
  • withEmptyOption(options, label = "— Não definido —") — a entrada "" de um campo anulável.
  • Nenhuma das duas muta a lista recebida.

Sem entrada vazia, salvar o formulário grava um valor que ninguém escolheu

Medido no jsdom (que implementa o algoritmo de seleção do HTML): um <select> controlado com value="" e sem opção vazia devolve a primeira opção (select.value === "a"). Abrir um formulário de edição e salvar grava esse valor. O placeholder do Select não resolve: a entrada dele é disabled hidden, então depois de escolher algo o campo nunca mais volta a vazio. Campo anulável usa withEmptyOption.

Objeto reordena chave numérica — use Map para escala

toOptions preserva a ordem de um Map sempre, e a de um objeto exceto nas chaves inteiras, que o JavaScript enumera primeiro e em ordem crescente (OrdinaryOwnPropertyKeys). Medido no Node 24: Object.keys({ 5: "Ótimo", 3: "Ok", 1: "Ruim" }) é ["1", "3", "5"]. Uma escala de humor não é crescente por acaso — passe um Map.

Toda chave vira value string, porque é string o que event.target.value devolve: uma opção com o número 0 volta como "0", e === nunca bateria. Por isso a lista serve em SelectOption[], MultiSelectOption[] e ComboboxOption[].

Variante bare — o mecanismo sem a vestimenta

As partes difíceis de um select customizado são appearance: none e um caret posicionado sobre o controle; a vestimenta (borda, fundo, raio, sombra, altura) é a fácil, e é justamente a que um app com identidade própria não quer. variant="bare" entrega só o mecanismo:

import { Select, toOptions, withAllOption } from "tempest-react-sdk";

const STATUS = { active: "Ativo", paused: "Pausado" };

export function BarraDeFiltros() {
    return (
        <div style={{ display: "flex", gap: 12 }}>
            <Select
                variant="bare"
                aria-label="Status"
                wrapperClassName="filtro-marca"
                options={withAllOption(toOptions(STATUS))}
            />
        </div>
    );
}
.filtro-marca {
    flex: 0 0 40%;
    height: 44px;
    padding: 0 4%;
    border-radius: 22px;
    background-color: #6d28d9;
    color: #fff;
}
  • Renderiza um único elemento de casca: wrapper > select + caret. A casca recebe wrapperClassName e é o item flex — não há outro div entre ela e a barra, então flex: 0 0 40% continua valendo.
  • Não pinta borda, fundo, raio, sombra nem altura. O <select> estica na altura da casca.
  • O <select> herda background-color e color da casca. O fundo não é estética: o menu nativo pinta cada <option> a partir do fundo do <select>, então um controle transparente devolveria o popup ao branco do sistema.
  • O anel de foco (--tempest-focus-ring-*) é desenhado na casca, por :focus-within, e segue o raio dela.
  • Como chip, não há rótulo visível: aria-label ou aria-labelledby é obrigatório no tipo.

O caret não é capturado pelo CSS do app

Medido no Chrome: uma regra de tela .fieldset svg { width: 18.4px; height: 100% } (especificidade 0-1-1) esticava o caret de 14 para 18,39 px. O slot do caret agora fixa o svg com seletor 0-2-1 e margin: 0, então regra de elemento do app não o alcança.

O caret — caretIcon e os tokens de densidade

O caret é posicionado e dimensionado por dois tokens de densidade, lidos por Select e Combobox:

Densidade --tempest-control-caret-offset --tempest-control-caret-size Altura md
compact 10px 12px 34px
comfortable 12px 14px 40px
touch 12px 16px 44px
spacious 14px 16px 44px

Antes, o caret ficava cravado a 12 px da borda em toda densidade (medido no Chrome, com o controle indo de 34 a 44 px e o raio de 4 a 12 px). A faixa reservada para o caret no padding do controle é calculada dos mesmos tokens, então afastar o caret afasta junto o fim do texto. No comfortable, os pixels são os de antes: 12 px de afastamento e 36 px de faixa no field, 10 px e 30 px no chip.

Afastar o caret da borda é um token, no escopo que você quiser:

.meu-formulario {
    --tempest-control-caret-offset: 18px;
}

E trocar o ícone é uma prop — o svg passado é esticado ao --tempest-control-caret-size, então o token, não o width do ícone, decide o tamanho em cada densidade:

import { ChevronsUpDown } from "lucide-react";
import { Select } from "tempest-react-sdk";

export function Ordenacao() {
    return (
        <Select
            label="Ordenar por"
            caretIcon={<ChevronsUpDown />}
            options={[
                { value: "recent", label: "Mais recentes" },
                { value: "name", label: "Nome" },
            ]}
        />
    );
}

Combobox

Toggle · Rating · Range · Combobox na gallery

Seção inputs-advanced da gallery — rode localmente para interagir.

Quando usar: uma opção entre muitas (dezenas+), onde o usuário precisa digitar para filtrar. Para poucas opções use Select.

Select com busca + filtro. Keyboard nav (↑↓ Enter Esc).

import { useState } from "react";
import { Combobox } from "tempest-react-sdk";

const CIDADES = [
    { value: "sp", label: "São Paulo" },
    { value: "rj", label: "Rio de Janeiro" },
    { value: "bh", label: "Belo Horizonte" },
];

export function Cidade() {
    const [city, setCity] = useState("sp");

    return (
        <Combobox
            label="Cidade"
            options={CIDADES}
            value={city}
            onChange={setCity}
            filter={(option, query) =>
                String(option.label).toLowerCase().includes(query.toLowerCase())
            }
        />
    );
}

A lista abre em portal

A lista é renderizada em document.body, com a largura do campo, e vira para cima quando não cabe embaixo. Por isso um Combobox na última linha de um DataTable — cujo wrapper rola e corta o que passa da borda — mostra a lista inteira. portal={false} devolve a lista em fluxo.

MultiSelect

Inputs avançados (Date · Pin · Slider) na gallery

Seção inputs-extra da gallery — rode localmente para interagir.

Quando usar: várias opções entre muitas, com busca e chips removíveis. Para uma única opção use Combobox; para poucas opções booleanas use Checkbox.

Multi-select filtrável com chips removíveis. Keyboard nav (↑↓ navega, Enter alterna, Esc fecha, Backspace com query vazia remove o último chip).

import { MultiSelect, type MultiSelectOption } from "tempest-react-sdk";
import { useState } from "react";

function Example() {
  const [sel, setSel] = useState<string[]>([]);
  const options: MultiSelectOption[] = [
    { value: "sp", label: "São Paulo" },
    { value: "rj", label: "Rio de Janeiro" },
  ];

  return <MultiSelect label="Estados" options={options} value={sel} onChange={setSel} />;
}
Prop Tipo Default
options MultiSelectOption[] — (obrigatório)
value string[] — (obrigatório, controlled)
onChange (value: string[]) => void — (obrigatório)
label string —
placeholder string "Selecione"
helperText string —
error string —
disabled boolean false
maxItems number —
filter (option, query) => boolean —
emptyMessage string "Nenhuma opção encontrada"
portal boolean (lista em document.body) true
className string —

MultiSelectOption é { value: string; label: string; disabled?: boolean }.

Checkbox

Checkbox · Radio · Switch na gallery

Seção form-primitives da gallery — rode localmente para interagir.

Single checkbox. Suporta indeterminate.

import { Checkbox } from "tempest-react-sdk";

export function Selecao({
    someSelected,
    allSelected,
}: {
    someSelected: boolean;
    allSelected: boolean;
}) {
    return (
        <>
            <Checkbox label="Aceito os termos" />
            <Checkbox label="Selecionar todos" indeterminate={someSelected && !allSelected} />
        </>
    );
}

Radio / RadioGroup

Radio standalone OU agrupado com value único.

import { useState } from "react";
import { Radio, RadioGroup } from "tempest-react-sdk";

export function Plano() {
    const [plan, setPlan] = useState("free");

    return (
        <fieldset>
            <legend>Plano</legend>
            <RadioGroup value={plan} onChange={setPlan}>
                <Radio value="free" label="Grátis" />
                <Radio value="pro" label="Pro" />
                <Radio value="team" label="Team" />
            </RadioGroup>
        </fieldset>
    );
}

RadioGroup não tem label — o rótulo do grupo é seu

O componente é o container de estado (value, onChange, name) e não renderiza título nenhum. Um grupo de rádios sem rótulo é anunciado só pelas opções, e o leitor de tela não diz do que elas são opções. Envolva num <fieldset> com <legend>, como no exemplo — é o que o HTML já resolve, e nenhum prop precisa existir para isso.

Switch

Quando usar: ligar/desligar uma preferência com efeito imediato (ex.: notificações). Para opt-in que só vale ao submeter o form (ex.: aceitar termos), prefira Checkbox.

Toggle on/off.

import { useState } from "react";
import { Switch } from "tempest-react-sdk";

export function Emails() {
    const [subscribed, setSubscribed] = useState(false);

    return (
        <Switch
            label="Receber emails"
            checked={subscribed}
            onChange={(event) => setSubscribed(event.target.checked)}
        />
    );
}

Switch vs Checkbox — não são intercambiáveis

Switch comunica uma ação que acontece agora; Checkbox comunica um estado que será aplicado depois (no submit). Trocar um pelo outro confunde o usuário sobre quando a mudança tem efeito.

ChipInput

Lista de chips com adição por Enter + dedup automático.

import { useState } from "react";
import { ChipInput } from "tempest-react-sdk";

export function Tags() {
    const [tags, setTags] = useState<string[]>([]);

    return (
        <ChipInput label="Tags" value={tags} onChange={setTags} placeholder="adicione e pressione Enter" />
    );
}

Input de busca com clear button + debounce opcional via useDebounce.

import { useState } from "react";
import { SearchBar } from "tempest-react-sdk";

export function Busca() {
    const [q, setQ] = useState("");

    return <SearchBar value={q} onChange={setQ} placeholder="O que você procura?" />;
}

DatePicker

<input type="date"> (ou time, datetime-local, month) com label/error.

import { useState } from "react";
import { DatePicker } from "tempest-react-sdk";

export function Datas() {
    const [date, setDate] = useState("2026-01-15");
    const [start, setStart] = useState("2026-01-15T09:00");

    return (
        <>
            <DatePicker
                label="Data"
                value={date}
                onChange={setDate}
                mode="date"
                min="2025-01-01"
            />
            <DatePicker
                label="Início"
                mode="datetime-local"
                value={start}
                onChange={setStart}
            />
        </>
    );
}

DateRangePicker

Quando usar: seleção de um intervalo de datas (início + fim) num calendário. Para uma única data use Calendar.

Calendário de intervalo: o primeiro clique define start, o próximo define end (reordenado automaticamente se for anterior), um terceiro clique recomeça; o dia sob o cursor pré-visualiza o intervalo. Matemática de Date pura, sem dependências.

import { DateRangePicker, type DateRange } from "tempest-react-sdk";
import { useState } from "react";

function Example() {
  const [range, setRange] = useState<DateRange>({ start: null, end: null });

  return <DateRangePicker value={range} onChange={setRange} numberOfMonths={2} />;
}
Prop Tipo Default
value DateRange — (obrigatório, controlled)
onChange (range: DateRange) => void — (obrigatório)
numberOfMonths number 2
defaultMonth Date —
minDate Date —
maxDate Date —
weekStartsOn 0 \| 1 0
className string —

DateRange é { start: Date | null; end: Date | null }.

TimePicker

Material (ListTile · FAB · Rail) na gallery

Seção material da gallery — rode localmente para interagir.

Quando usar: escolher um horário em colunas roláveis (estilo "spinner" do Material) — agendamentos, lembretes, janelas de atendimento. Para um campo nativo simples, use o DatePicker com mode="time".

Sempre emite uma string 24h "HH:MM" via onChange, mesmo com use12Hours ligado. O minuteStep controla a granularidade da coluna de minutos.

import { useState } from "react";
import { TimePicker } from "tempest-react-sdk";

function ScheduleField() {
  const [t, setT] = useState("09:30");

  return (
    <TimePicker
      label="Horário"
      value={t}
      onChange={setT}
      minuteStep={15}
      helperText="Selecione hora e minuto"
    />
  );
}
Prop Tipo Default
value string (24h "HH:MM") —
onChange (value: string) => void —
minuteStep number (granularidade) 5
use12Hours boolean (colunas 1–12 + AM/PM) false
label string —
helperText string —
disabled boolean false

Sempre 24h na saída

Mesmo com use12Hours (colunas 1–12 + AM/PM), o onChange continua emitindo "HH:MM" em 24h — o display 12h é só visual. Guarde e envie o valor de 24h direto.

FileUpload

Stepper · Progress · VirtualList na gallery

Seção advanced da gallery — rode localmente para interagir.

Drag-and-drop + click-to-upload + lista de arquivos.

import { useState } from "react";
import { FileUpload } from "tempest-react-sdk";

export function Anexos() {
    const [files, setFiles] = useState<File[]>([]);

    return (
        <FileUpload
            label="Anexar"
            accept="image/*"
            multiple
            value={files}
            onChange={setFiles}
            maxSize={5 * 1024 * 1024}
        />
    );
}

Slider

Quando usar: escolher um único valor numa faixa contínua (volume, brilho, etc.). Para uma faixa de dois valores use RangeSlider.

Slider de thumb único sobre <input type="range"> nativo.

import { Slider } from "tempest-react-sdk";
import { useState } from "react";

function Example() {
  const [vol, setVol] = useState(30);

  return <Slider value={vol} onChange={setVol} label="Volume" formatValue={(v) => v + "%"} />;
}
Prop Tipo Default
value number — (obrigatório, controlled)
onChange (value: number) => void — (obrigatório)
min number 0
max number 100
step number 1
label string —
helperText string —
disabled boolean false
formatValue (value: number) => string —
aria-label string —
className string —

Slider compacto: nome acessível sem o label visível

Passar label renderiza um bloco acima da track (rótulo + valor). Onde esse bloco não cabe — rodapé de uma linha, célula de tabela, toolbar — use aria-label sozinho:

<Slider value={gain} onChange={setGain} aria-label={`Volume de ${nome}`} />

Sem isso, todo slider da página se anuncia como "Slider" e o leitor de tela não distingue um do outro. A precedência é aria-label → label → "Slider", então quem já passava só label não muda em nada. Envolver o campo num <label> externo não resolve: um aria-label explícito no input vence na ordem de precedência do nome acessível.

Dropzone

Quando usar: uma área de arrastar-e-soltar enxuta, quando você só precisa capturar os arquivos (onDrop) e renderizar a lista/preview por conta própria. Para um campo pronto com label, lista de arquivos e estilo de formulário, use FileUpload.

Área drag-and-drop com input de arquivo escondido — clicável e focável por teclado. Filtra por maxSize antes de chamar onDrop; rejeitados vão em onReject.

import { useState } from "react";
import { Dropzone } from "tempest-react-sdk";

function Uploader() {
  const [files, setFiles] = useState<File[]>([]);
  return (
    <>
      <Dropzone
        accept="image/*"
        multiple
        maxSize={5 * 1024 * 1024}
        onDrop={(accepted) => setFiles(accepted)}
        onReject={(rejected) => alert(`${rejected.length} arquivo(s) acima de 5 MB`)}
      >
        Arraste imagens aqui ou clique para selecionar
      </Dropzone>
      <ul>
        {files.map((file) => (
          <li key={file.name}>{file.name}</li>
        ))}
      </ul>
    </>
  );
}
Prop Tipo Default
onDrop (files: File[]) => void —
accept string —
multiple boolean true
maxSize number (bytes) —
onReject (files: File[]) => void —
disabled boolean false
children ReactNode prompt padrão
className string —

A11y: role="button" + tabIndex (Enter/Espaço abrem o seletor); aria-disabled quando disabled.

RangeSlider

Dual-thumb slider com clamp low ≤ high.

import { useState } from "react";
import { RangeSlider } from "tempest-react-sdk";

export function FaixaDePreco() {
    const [range, setRange] = useState<[number, number]>([100, 800]);

    return (
        <RangeSlider
            label="Faixa de preço"
            min={0}
            max={1000}
            step={10}
            value={range}
            onChange={setRange}
            formatValue={([lo, hi]) => `R$ ${lo} – R$ ${hi}`}
        />
    );
}

Aceita aria-label pelo mesmo motivo do Slider. Cada thumb continua com nome próprio — "Faixa de preço (mínimo)" e "Faixa de preço (máximo)" — porque quem navega de um para o outro precisa saber em qual ponta está.

RatingStars

Radio group de estrelas.

import { useState } from "react";
import { RatingStars } from "tempest-react-sdk";

export function Avaliacao() {
    const [rating, setRating] = useState(4);

    return (
        <>
            <RatingStars value={rating} onChange={setRating} max={5} size="md" />
            <RatingStars value={4.5} readonly size="lg" />
        </>
    );
}

PinInput

Quando usar: códigos de verificação curtos (OTP, 2FA, confirmação por SMS/email). Para senhas use PasswordInput.

OTP / one-time-code com N células. Paste, auto-advance, backspace flowback, arrow nav.

Colar o código inteiro funciona

O usuário pode colar 123456 em qualquer célula que o PinInput distribui os dígitos automaticamente — defina type="numeric" para que o teclado mobile abra no modo numérico.

import { PinInput } from "tempest-react-sdk";

export function Codigos({ verify }: { verify: (code: string) => void }) {
    return (
        <>
            <PinInput length={6} type="numeric" onComplete={verify} />
            <PinInput length={4} type="alphanumeric" masked autoFocus />
        </>
    );
}
Prop Tipo Default
length number 6
type "numeric" \| "alphanumeric" "numeric"
value string — (controlled)
defaultValue string ""
onChange (value: string) => void —
onComplete (value: string) => void —
masked boolean false
size "sm" \| "md" \| "lg" "md"
autoFocus boolean false

PasswordInput

Field tipo password com toggle de visibilidade + strength meter opcional (5 níveis).

import { PasswordInput } from "tempest-react-sdk";

export function Senha() {
    return <PasswordInput label="Senha" autoComplete="new-password" showStrength />;
}

Helper exposto: estimatePasswordStrength(value) retorna 0-4 (length, case mix, digits, symbols).

Use autoComplete correto

Em telas de cadastro use autoComplete="new-password"; em login use autoComplete="current-password". O valor errado faz o gerenciador de senhas do navegador sugerir/salvar a senha de forma incorreta.

Prop Tipo Default
showStrength boolean false
strength 0 \| 1 \| 2 \| 3 \| 4 (override manual) estimatePasswordStrength(value)
strengthLabels [string,string,string,string,string] ["Muito fraca","Fraca","Razoável","Forte","Excelente"]
toggleLabels { show, hide } { show: "Mostrar senha", hide: "Esconder senha" }

StepperInput

+ / − numeric com clamp em min/max.

import { useState } from "react";
import { StepperInput } from "tempest-react-sdk";

export function Quantidades() {
    const [qty, setQty] = useState(1);
    const [price, setPrice] = useState(50);

    return (
        <>
            <StepperInput value={qty} onChange={setQty} min={1} max={10} />
            <StepperInput
                value={price}
                onChange={setPrice}
                step={5}
                format={(value) => `R$ ${value}`}
            />
        </>
    );
}

Form / FormSection / FormRow / FormActions / FormField

Forms (zod) na gallery

Seção forms da gallery — rode localmente para interagir.

Layout wrappers para forms (stack/inline/grid) + integração RHF.

import { Button, Form, FormActions, Input } from "tempest-react-sdk";

export function Cadastro() {
    return (
        <Form layout="grid" columns={2} gap={4}>
            <Input label="Nome" />
            <Input label="Email" type="email" />
            <FormActions align="end">
                <Button type="submit">Salvar</Button>
            </FormActions>
        </Form>
    );
}

Detalhes completos em ../forms.md.

ImageCropper

ImageCropper (recorte) na gallery

Seção image-cropper da gallery — rode localmente para interagir.

Quando usar: par natural do FileUpload — foto de perfil, foto de documento, capa. O app decide a proporção de saída; o usuário só escolhe o que cai dentro dela.

O frame fica parado e a imagem pana e dá zoom atrás dele. É o modelo que um fluxo de avatar quer: por construção não existe recorte fora da proporção.

import { useRef, useState } from "react";
import { Button, FileUpload, ImageCropper, type ImageCropperHandle } from "tempest-react-sdk";

export function AvatarField({ onSave }: { onSave: (blob: Blob) => void }) {
  const [files, setFiles] = useState<File[]>([]);
  const cropper = useRef<ImageCropperHandle>(null);

  return (
    <>
      <FileUpload value={files} onChange={setFiles} accept="image/*" label="Foto" />
      {files[0] && (
        <>
          <ImageCropper
            ref={cropper}
            src={files[0]}
            aspect={1}
            shape="circle"
            maxSize={512}
            outputType="image/jpeg"
          />
          <Button
            onClick={async () => {
              const blob = await cropper.current?.crop();
              if (blob) onSave(blob);
            }}
          >
            Salvar
          </Button>
        </>
      )}
    </>
  );
}
Prop Tipo Default
src File \| Blob \| string —
aspect number (largura / altura) 1
maxZoom number 4
maxSize number (teto da maior aresta exportada, px) —
outputType string "image/png"
outputQuality number (0–1, tipos com perda) 0.92
shape "rect" \| "circle" "rect"
onCropChange ({ zoom, offset }) => void —
label string (nome acessível da área) "Área de recorte"
ref Ref<ImageCropperHandle> —

O ref expõe { crop, reset }. O crop() devolve Promise<Blob | null>.

Exporta os pixels originais, não o preview

O recorte é lido do tamanho natural da imagem via canvas. Uma foto de 4000 px recortada num preview de 320 px sai com a resolução do original, não com a do preview — que é o erro mais comum em cropper caseiro.

Use maxSize pra limitar: uma foto de 12 MP recortada pra um avatar de 96 px são megabytes de desperdício.

Nunca sai borda vazia

A imagem é sempre clampada pra cobrir o frame, em pan e em zoom. É o outro defeito clássico: arrastar ou dar zoom-out até o frame mostrar fundo, e aí a faixa transparente (ou preta) fica assada no arquivo exportado. Aqui é impossível por construção — inclusive ao dar zoom-out, quando um offset que era legal deixa de ser.

Teclado de igual peso

A área de recorte é focável. Setas movem (com Shift movem 4×), +/− dão zoom, 0 centraliza. Roda do mouse também dá zoom. Um cropper que só funciona arrastando exclui quem navega por teclado.

crop() devolve null, não lança

Antes da imagem carregar, ou se o navegador recusar o encode, o retorno é null. Um handler de submit não precisa de try/catch — precisa checar o retorno.

File/Blob viram object URL, e ele é revogado

Trocar de foto ou desmontar o componente revoga a URL anterior. Sem isso, cada re-escolha de foto vazaria a anterior pelo resto da vida do documento.

SignaturePad

SignaturePad · Lightbox · AvatarGroup na gallery

Seção capture-media da gallery — rode localmente para interagir.

Quando usar: coletar assinatura de próprio punho — comprovante de entrega, ordem de serviço, termo de aceite. Em campo, no celular, com o dedo.

Canvas com captura por pointer (mouse, dedo e caneta pelo mesmo caminho). Os traços são guardados como listas de pontos e o canvas é redesenhado a partir delas — é isso que torna o undo possível: canvas guarda pixel, não histórico, então remover o último traço significa repintar o resto.

import { Button, SignaturePad, type SignaturePadHandle } from "tempest-react-sdk";
import { useRef, useState } from "react";

export function AssinaturaDaEntrega({ entregaId }: { entregaId: string }) {
  const pad = useRef<SignaturePadHandle>(null);
  const [vazio, setVazio] = useState(true);

  async function enviar() {
    const blob = await pad.current?.toBlob("image/png");
    if (!blob) return;
    const form = new FormData();
    form.append("assinatura", blob, `${entregaId}.png`);
    await api.post(`/entregas/${entregaId}/assinatura`, form);
  }

  return (
    <>
      <SignaturePad
        label="Assinatura do cliente"
        width={360}
        height={180}
        onEmptyChange={setVazio}
      />
      <Button disabled={vazio} onClick={enviar}>Confirmar entrega</Button>
    </>
  );
}
Prop Tipo Default O que faz
width number 400 Largura da superfície em px CSS.
height number 160 Altura da superfície em px CSS.
penColor string cor computada Cor do traço. O default segue --tempest-text.
penWidth number 2 Espessura do traço.
disabled boolean false Bloqueia o desenho e esmaece a superfície.
label string "Signature" Nome acessível do canvas.
onBegin () => void — Chamado no início de cada traço.
onEnd (dataUrl: string) => void — Chamado ao fim de cada traço, com a imagem atual.
onEmptyChange (isEmpty: boolean) => void — Chamado quando a vacuidade muda — ligue no botão de submit.
showActions boolean true Renderiza os botões Desfazer/Limpar.

Handle imperativo (ref): clear(), undo(), isEmpty(), toDataURL(type?, quality?), toBlob(type?, quality?).

Suba toBlob(), não toDataURL()

Data URL é base64: ~33% mais bytes, e ainda vira string no meio do seu JSON. toBlob() entrega binário pronto pro FormData.

Nitidez em tela de alta densidade

O buffer do canvas é escalado por devicePixelRatio e o contexto recebe o setTransform correspondente. Sem isso o traço sai borrado no celular — é o defeito clássico de canvas em 1x.

A cor do traço segue o tema

O default lê a cor computada do canvas, que o CSS liga em --tempest-text. Assinatura desenhada no tema escuro sai clara; no claro, escura. Passe penColor só se precisar de tinta fixa (azul de caneta, por exemplo).

A11y

  • Sempre use label — screen readers anunciam o campo.
  • error adiciona aria-invalid="true" + descreve via aria-describedby.
  • required propaga atributo required nativo + indicador visual *.
  • PinInput cells expõem aria-label="Dígito N" individuais.
  • PasswordInput.toggle usa aria-pressed e label aria-label localizada.

Resumo

  • Escolha o controle pelo tipo de dado — não force um Input onde um Select, Switch ou PinInput comunica melhor a intenção.
  • Todos os campos compartilham label / helperText / error / required / size e fazem forward de ref → plugam direto em react-hook-form.
  • error substitui helperText e adiciona aria-invalid automaticamente — não duplique a mensagem.

Páginas relacionadas:

  • Validação de formulários — validateForm, useZodForm, máscaras BR, useViaCEP e o wrapper <FormField>.
  • Layout — Form/FormSection/FormRow/FormActions para estruturar os campos.
  • Ações — Button para o submit do formulário.
  • Status & feedback — Alert/Toast para confirmar sucesso ou erro do envio.