Campo de Etiquetas
Campo que transforma o texto em etiquetas: Enter ou vírgula adiciona, Backspace apaga a última, × remove e colar uma lista cria várias. Ignora repetidas, respeita um máximo e aceita sugestões.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<CampoDeEtiquetas>
Campo de etiquetas: digite e tecle Enter (ou um separador, como a vírgula) para virar etiqueta. Backspace com o campo vazio apaga a última, o × remove qualquer uma e colar "a, b, c" cria várias de uma vez. Ignora repetidas e respeita um máximo, avisando por aria-live.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotuloobrigatória | string | — | |
| valorobrigatória | string[] | — | |
| aoMudarobrigatória | (etiquetas: string[]) => void | — | |
| sugestoes | string[] | — | Lista de sugestões do navegador (datalist); as já escolhidas somem dela. |
| maximo | number | — | Quantidade máxima de etiquetas. |
| separadores | string[] | [","] | Teclas que também fecham a etiqueta e separam o texto colado (Enter e quebra de linha sempre valem). |
| permitirRepetidas | boolean | false | Por padrão, uma etiqueta igual a outra (sem diferenciar maiúsculas) é ignorada. |
| ajuda | string | — | Texto de apoio abaixo do campo. |
| desativado | boolean | false | |
| textos | Partial<TextosDasEtiquetas> | — |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, useRef, useState, type ChangeEvent, type ClipboardEvent, type KeyboardEvent } from "react";
/** Textos do campo; troque para concordar com o que é digitado ("Remover cenário X"...). */
export type TextosDasEtiquetas = {
placeholder: string;
/** Nome do botão × de cada etiqueta. */
remover: (etiqueta: string) => string;
repetida: (etiqueta: string) => string;
limite: (maximo: number) => string;
contador: (quantidade: number, maximo: number) => string;
};
const TEXTOS_PADRAO: TextosDasEtiquetas = {
placeholder: "Digite e tecle Enter",
remover: (etiqueta) => `Remover ${etiqueta}`,
repetida: (etiqueta) => `“${etiqueta}” já está na lista.`,
limite: (maximo) => `Limite de ${maximo} atingido.`,
contador: (quantidade, maximo) => `${quantidade} de ${maximo}`,
};
const comparavel = (texto: string) => texto.trim().toLocaleLowerCase("pt-BR");
/**
* Campo de etiquetas: digite e tecle Enter (ou um separador, como a vírgula) para
* virar etiqueta. Backspace com o campo vazio apaga a última, o × remove qualquer
* uma e colar "a, b, c" cria várias de uma vez. Ignora repetidas e respeita um
* máximo, avisando por aria-live.
*/
export function CampoDeEtiquetas({
rotulo,
valor,
aoMudar,
sugestoes,
maximo,
separadores = [","],
permitirRepetidas = false,
ajuda,
desativado = false,
textos: textosParciais,
}: {
rotulo: string;
valor: string[];
aoMudar: (etiquetas: string[]) => void;
/** Lista de sugestões do navegador (datalist); as já escolhidas somem dela. */
sugestoes?: string[];
/** Quantidade máxima de etiquetas. */
maximo?: number;
/** Teclas que também fecham a etiqueta e separam o texto colado (Enter e quebra de linha sempre valem). */
separadores?: string[];
/** Por padrão, uma etiqueta igual a outra (sem diferenciar maiúsculas) é ignorada. */
permitirRepetidas?: boolean;
/** Texto de apoio abaixo do campo. */
ajuda?: string;
desativado?: boolean;
textos?: Partial<TextosDasEtiquetas>;
}) {
const textos = { ...TEXTOS_PADRAO, ...textosParciais };
const id = useId();
const campo = useRef<HTMLInputElement>(null);
const [rascunho, setRascunho] = useState("");
const [mensagem, setMensagem] = useState("");
const noLimite = maximo !== undefined && valor.length >= maximo;
const dividir = (texto: string) => {
let partes = [texto];
for (const separador of [...separadores, "\n"]) partes = partes.flatMap((p) => p.split(separador));
return partes;
};
/** Acrescenta as que puder, na ordem, e avisa da última que ficou de fora. Falso se parou no limite. */
function adicionar(novas: string[]): boolean {
const lista = [...valor];
let aviso = "";
let parou = false;
for (const bruta of novas) {
const etiqueta = bruta.trim();
if (!etiqueta) continue;
if (maximo !== undefined && lista.length >= maximo) {
aviso = textos.limite(maximo);
parou = true;
break;
}
if (!permitirRepetidas && lista.some((e) => comparavel(e) === comparavel(etiqueta))) {
aviso = textos.repetida(etiqueta);
continue;
}
lista.push(etiqueta);
}
if (lista.length !== valor.length) aoMudar(lista);
setMensagem(aviso);
return !parou;
}
function remover(indice: number) {
aoMudar(valor.filter((_, i) => i !== indice));
setMensagem("");
campo.current?.focus();
}
function aoDigitar(e: ChangeEvent<HTMLInputElement>) {
const texto = e.target.value;
const tipo = (e.nativeEvent as InputEvent).inputType;
// Escolha na lista de sugestões chega inteira, sem digitação: já vira etiqueta
if (sugestoes?.includes(texto) && (tipo === undefined || tipo === "insertReplacementText")) {
adicionar([texto]);
setRascunho("");
return;
}
const partes = dividir(texto);
if (partes.length > 1) {
// O que veio antes do separador vira etiqueta; o resto continua no campo
adicionar(partes.slice(0, -1));
setRascunho(partes[partes.length - 1]);
return;
}
setRascunho(texto);
if (mensagem) setMensagem("");
}
function aoTeclar(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" && rascunho.trim()) {
// Não envia o formulário em volta
e.preventDefault();
// No limite, o texto fica no campo para não se perder
if (adicionar([rascunho])) setRascunho("");
} else if (e.key === "Backspace" && rascunho === "" && valor.length > 0) {
e.preventDefault();
aoMudar(valor.slice(0, -1));
setMensagem("");
}
}
function aoColar(e: ClipboardEvent<HTMLInputElement>) {
const colado = e.clipboardData.getData("text");
const partes = dividir(colado);
if (partes.length < 2) return;
e.preventDefault();
adicionar(dividir(rascunho + partes[0]).concat(partes.slice(1)));
setRascunho("");
}
function aoSair() {
if (rascunho.trim() && adicionar([rascunho])) setRascunho("");
}
const disponiveis = sugestoes?.filter((s) => !valor.some((e) => comparavel(e) === comparavel(s)));
const idAjuda = `${id}-ajuda`;
const temRodape = ajuda || maximo !== undefined || mensagem;
return (
<div className="flex w-full flex-col gap-2">
<label htmlFor={id} className="text-sm font-semibold text-ink">
{rotulo}
</label>
{/* Clicar no espaço vazio da caixa leva ao campo de texto */}
<div
onClick={(e) => {
if (e.target === e.currentTarget) campo.current?.focus();
}}
className={`flex min-h-11 flex-wrap items-center gap-1.5 rounded-lg border bg-page px-2 py-1.5 outline-accent focus-within:outline-2 focus-within:outline-offset-2 ${
desativado ? "cursor-not-allowed border-rule opacity-60" : "cursor-text border-rule-strong focus-within:border-accent"
}`}
>
{valor.length > 0 && (
<ul aria-label={rotulo} className="flex flex-wrap gap-1.5">
{valor.map((etiqueta, i) => (
<li
key={`${etiqueta}-${i}`}
className="inline-flex max-w-full items-center gap-1 rounded-[max(var(--radius-sm),calc(var(--radius-lg)_-_var(--spacing)*2))] border border-rule bg-surface py-0.5 pr-1 pl-3 text-sm text-ink"
>
<span className="min-w-0 truncate">{etiqueta}</span>
{!desativado && (
<button
type="button"
onClick={() => remover(i)}
aria-label={textos.remover(etiqueta)}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-[max(var(--radius-sm),calc(var(--radius-lg)_-_var(--spacing)*3))] text-muted outline-accent hover:bg-page hover:text-accent focus-visible:outline-2"
>
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
)}
</li>
))}
</ul>
)}
<input
ref={campo}
id={id}
value={rascunho}
onChange={aoDigitar}
onKeyDown={aoTeclar}
onPaste={aoColar}
onBlur={aoSair}
disabled={desativado}
list={disponiveis?.length ? `${id}-sugestoes` : undefined}
aria-describedby={temRodape ? idAjuda : undefined}
placeholder={noLimite ? "" : textos.placeholder}
autoComplete="off"
enterKeyHint="enter"
className="min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm text-ink outline-none placeholder:text-muted disabled:cursor-not-allowed"
/>
{disponiveis && disponiveis.length > 0 && (
<datalist id={`${id}-sugestoes`}>
{disponiveis.map((s) => (
<option key={s} value={s} />
))}
</datalist>
)}
</div>
{/* Região de anúncio sempre presente (precisa existir antes da mensagem para ser lida) */}
<p aria-live="polite" className="sr-only">
{mensagem}
</p>
{temRodape && (
<div id={idAjuda} className="flex items-start justify-between gap-3 text-xs">
{mensagem ? (
<p aria-hidden="true" className="font-medium text-ink-2">
{mensagem}
</p>
) : (
<p className="text-muted">{ajuda}</p>
)}
{maximo !== undefined && (
<span className={`shrink-0 tabular-nums ${noLimite ? "font-semibold text-ink-2" : "text-muted"}`}>
{textos.contador(valor.length, maximo)}
</span>
)}
</div>
)}
</div>
);
}
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { CampoDeEtiquetas } from "./CampoDeEtiquetas";
const SUGESTOES = ["Atacado", "Varejo", "Assinatura", "Marketplace", "Revenda", "Exportação"];
function Exemplo({
rotulo,
maximo,
comSugestoes,
pontoEVirgula,
ajuda,
inicial = ["Varejo", "Assinatura"],
}: {
rotulo: string;
maximo: number;
comSugestoes: boolean;
pontoEVirgula: boolean;
ajuda: string;
inicial?: string[];
}) {
const [etiquetas, setEtiquetas] = useState(inicial);
return (
<div className="w-full max-w-lg">
<CampoDeEtiquetas
rotulo={rotulo}
valor={etiquetas}
aoMudar={setEtiquetas}
maximo={maximo > 0 ? maximo : undefined}
sugestoes={comSugestoes ? SUGESTOES : undefined}
separadores={pontoEVirgula ? [",", ";"] : [","]}
ajuda={ajuda || undefined}
/>
</div>
);
}
export default defineStories({
args: {
rotulo: "Canais de venda",
maximo: 0,
comSugestoes: true,
pontoEVirgula: false,
ajuda: "Separe por vírgula ou tecle Enter. Cole uma lista para criar várias.",
},
controls: {
rotulo: { type: "text" },
maximo: { type: "number", label: "maximo (0 = sem limite)", min: 0, max: 10 },
comSugestoes: { type: "boolean", label: "sugestões (datalist)" },
pontoEVirgula: { type: "boolean", label: "“;” também separa" },
ajuda: { type: "text" },
},
render: (a) => <Exemplo {...a} />,
stories: [
{ name: "Etiquetas" },
{ name: "Com limite", args: { rotulo: "Palavras-chave", maximo: 3, comSugestoes: false, ajuda: "" } },
{
name: "Vazio, separando por “;”",
render: () => (
<Exemplo
rotulo="Cenários da proposta"
maximo={0}
comSugestoes={false}
pontoEVirgula
ajuda="Ex.: Base; Otimista; Pessimista"
inicial={[]}
/>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}