Lista de Tarefas
Checklist controlado com barra de progresso (“3 de 5”), campo para adicionar, marcar como feito, edição no lugar (duplo clique ou botão; Enter salva, Esc cancela), remover e reordenar com botões de subir e descer.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<ListaDeTarefas>
Checklist controlado: barra de progresso com "x de y", campo para adicionar, marcar como feito, edição no lugar (duplo clique no texto ou botão de editar; Enter salva, Esc cancela), remover e, opcionalmente, reordenar com botões.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | ItemDaLista[] | — | |
| aoMudarobrigatória | (itens: ItemDaLista[]) => void | — | Recebe a lista inteira já alterada. |
| rotulo | string | "Lista de tarefas" | Nome da lista para leitores de tela. |
| reordenavel | boolean | true | Mostra os botões de subir e descer. |
| editavel | boolean | true | Permite adicionar, editar e remover (desligado, só marca e desmarca). |
| textos | Partial<TextosDaLista> | — |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
export type ItemDaLista = { id: string; texto: string; feito: boolean };
/** Textos da lista; troque para o contexto ("Novo passo...", "Nenhum passo ainda"). */
export type TextosDaLista = {
progresso: string;
/** Recebe feitos e total: "3 de 5". */
contagem: (feitos: number, total: number) => string;
novoItem: string;
adicionar: string;
vazio: string;
editar: string;
subir: string;
descer: string;
remover: string;
};
const TEXTOS_PADRAO: TextosDaLista = {
progresso: "Progresso",
contagem: (feitos, total) => `${feitos} de ${total}`,
novoItem: "Nova tarefa...",
adicionar: "Adicionar tarefa",
vazio: "Nenhuma tarefa ainda. Use o campo acima para adicionar a primeira.",
editar: "Editar",
subir: "Mover para cima",
descer: "Mover para baixo",
remover: "Remover",
};
function novoId() {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
const svg = (children: ReactNode) => (
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const BOTAO_BASE =
"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*6))] text-muted outline-accent focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent";
const BOTAO_ICONE = `${BOTAO_BASE} hover:bg-surface hover:text-ink`;
/** Foco a restaurar depois que a lista renderiza de novo (o item mudou de lugar ou saiu). */
type FocoPendente = { id: string; alvo: "caixa" | "subir" | "descer" | "editar" } | { id: null; alvo: "novo" };
/**
* Checklist controlado: barra de progresso com "x de y", campo para adicionar,
* marcar como feito, edição no lugar (duplo clique no texto ou botão de editar;
* Enter salva, Esc cancela), remover e, opcionalmente, reordenar com botões.
*/
export function ListaDeTarefas({
itens,
aoMudar,
rotulo = "Lista de tarefas",
reordenavel = true,
editavel = true,
textos: textosParciais,
}: {
itens: ItemDaLista[];
/** Recebe a lista inteira já alterada. */
aoMudar: (itens: ItemDaLista[]) => void;
/** Nome da lista para leitores de tela. */
rotulo?: string;
/** Mostra os botões de subir e descer. */
reordenavel?: boolean;
/** Permite adicionar, editar e remover (desligado, só marca e desmarca). */
editavel?: boolean;
textos?: Partial<TextosDaLista>;
}) {
const textos = { ...TEXTOS_PADRAO, ...textosParciais };
const semMovimento = useSemMovimento();
const idLista = useId();
const [novo, setNovo] = useState("");
const [editando, setEditando] = useState<string | null>(null);
const [rascunho, setRascunho] = useState("");
const raiz = useRef<HTMLDivElement>(null);
const campoNovo = useRef<HTMLInputElement>(null);
const focoPendente = useRef<FocoPendente | null>(null);
const edicaoAtiva = useRef<string | null>(null);
const feitos = itens.filter((i) => i.feito).length;
const total = itens.length;
const pct = total === 0 ? 0 : Math.round((feitos / total) * 100);
// Depois de mover ou remover, o React recria ou desloca a linha e o foco se perde: devolve ao lugar certo.
useEffect(() => {
const pendente = focoPendente.current;
if (!pendente) return;
focoPendente.current = null;
if (pendente.alvo === "novo") {
campoNovo.current?.focus();
return;
}
const linha = raiz.current?.querySelector<HTMLElement>(`[data-item="${CSS.escape(pendente.id)}"]`);
let el = linha?.querySelector<HTMLElement>(`[data-foco="${pendente.alvo}"]`);
// Chegou na ponta: o botão ficou desabilitado, então o foco vai para o do sentido oposto.
if (el instanceof HTMLButtonElement && el.disabled) {
el = linha?.querySelector<HTMLElement>(`[data-foco="${pendente.alvo === "subir" ? "descer" : "subir"}"]`);
}
el?.focus();
});
function adicionar() {
const texto = novo.trim();
if (!texto) return;
aoMudar([...itens, { id: novoId(), texto, feito: false }]);
setNovo("");
}
function alternar(id: string) {
aoMudar(itens.map((i) => (i.id === id ? { ...i, feito: !i.feito } : i)));
}
function comecarEdicao(item: ItemDaLista) {
if (!editavel) return;
edicaoAtiva.current = item.id;
setEditando(item.id);
setRascunho(item.texto);
}
/**
* Fecha a edição (salvando ou não). O campo some ao fechar e o navegador ainda
* dispara o blur dele: a ref impede que esse blur salve o que o Esc cancelou.
* O foco só volta ao botão de editar quando a edição termina pelo teclado.
*/
function terminarEdicao(item: ItemDaLista, salvar: boolean, devolverFoco: boolean) {
if (edicaoAtiva.current !== item.id) return;
edicaoAtiva.current = null;
setEditando(null);
if (devolverFoco) focoPendente.current = { id: item.id, alvo: "editar" };
const texto = rascunho.trim();
// Texto vazio não apaga o item: só cancela a edição.
if (salvar && texto && texto !== item.texto) aoMudar(itens.map((i) => (i.id === item.id ? { ...i, texto } : i)));
}
function mover(indice: number, passo: -1 | 1) {
const destino = indice + passo;
if (destino < 0 || destino >= itens.length) return;
const nova = [...itens];
[nova[indice], nova[destino]] = [nova[destino], nova[indice]];
focoPendente.current = { id: itens[indice].id, alvo: passo === -1 ? "subir" : "descer" };
aoMudar(nova);
}
function remover(indice: number) {
const vizinho = itens[indice + 1] ?? itens[indice - 1];
focoPendente.current = vizinho ? { id: vizinho.id, alvo: "caixa" } : { id: null, alvo: "novo" };
aoMudar(itens.filter((_, i) => i !== indice));
}
return (
<div ref={raiz} role="group" aria-label={rotulo} className="flex flex-col gap-4">
<div className="rounded-xl border border-rule bg-surface p-4">
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-ink-2">{textos.progresso}</span>
<span aria-live="polite" className="text-muted tabular-nums">
{total === 0 ? "—" : `${textos.contagem(feitos, total)} (${pct}%)`}
</span>
</div>
<div
role="progressbar"
aria-label={textos.progresso}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
className="h-2 overflow-hidden rounded-full bg-track"
>
<div
className={`h-full rounded-full ${pct === 100 ? "bg-success" : "bg-accent"} ${semMovimento ? "" : "transition-[width] duration-300 ease-out"}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
{editavel && (
<div className="flex items-stretch gap-2">
<input
ref={campoNovo}
value={novo}
onChange={(e) => setNovo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
adicionar();
}
}}
placeholder={textos.novoItem}
aria-label={textos.novoItem}
className="h-11 min-w-0 flex-1 rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*4))] border border-rule bg-page px-3 text-sm text-ink outline-accent placeholder:text-muted focus-visible:outline-2 focus-visible:-outline-offset-1"
/>
<button
type="button"
onClick={adicionar}
disabled={!novo.trim()}
aria-label={textos.adicionar}
title={textos.adicionar}
className="flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*4))] bg-accent text-page outline-accent hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-40"
>
{svg(<path d="M12 5v14M5 12h14" />)}
</button>
</div>
)}
{itens.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule px-4 py-6 text-center text-sm text-muted">{textos.vazio}</p>
) : (
<ul className="flex flex-col gap-1.5">
{itens.map((item, i) => {
const idTexto = `${idLista}-${i}`; // pelo índice: o id de quem usa pode ter espaços
const emEdicao = editando === item.id;
return (
<li
key={item.id}
data-item={item.id}
className={`group flex min-h-12 items-center gap-3 rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*4))] border px-3 py-1.5 ${
item.feito ? "border-success/30 bg-success/5" : "border-rule bg-page hover:bg-surface"
}`}
>
<span className="relative flex shrink-0">
<input
type="checkbox"
data-foco="caixa"
checked={item.feito}
onChange={() => alternar(item.id)}
aria-labelledby={idTexto}
className="peer size-5 cursor-pointer appearance-none rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*7))] border border-rule-strong bg-page outline-accent checked:border-success checked:bg-success focus-visible:outline-2 focus-visible:outline-offset-2"
/>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="pointer-events-none absolute inset-0 m-auto hidden size-3.5 text-page peer-checked:block"
fill="none"
stroke="currentColor"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m5 12.5 4.5 4.5L19 7.5" />
</svg>
</span>
{emEdicao ? (
<input
ref={(el) => el?.focus()}
id={idTexto}
value={rascunho}
aria-label={textos.editar}
onChange={(e) => setRascunho(e.target.value)}
onBlur={() => terminarEdicao(item, true, false)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
terminarEdicao(item, true, true);
} else if (e.key === "Escape") {
e.preventDefault();
terminarEdicao(item, false, true);
}
}}
className="h-9 min-w-0 flex-1 rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*6))] border border-accent bg-page px-2 text-sm text-ink outline-none"
/>
) : (
<span
id={idTexto}
onDoubleClick={() => comecarEdicao(item)}
className={`min-w-0 flex-1 text-sm break-words ${item.feito ? "text-muted line-through" : "text-ink"} ${editavel ? "cursor-text" : ""}`}
>
{item.texto}
</span>
)}
{/* Com mouse, as ações aparecem no hover/foco; em telas de toque ficam sempre visíveis. */}
{!emEdicao && (editavel || reordenavel) && (
<div className="flex shrink-0 items-center gap-0.5 pointer-fine:opacity-0 pointer-fine:group-focus-within:opacity-100 pointer-fine:group-hover:opacity-100">
{editavel && (
<button type="button" data-foco="editar" onClick={() => comecarEdicao(item)} aria-label={`${textos.editar}: ${item.texto}`} title={textos.editar} className={BOTAO_ICONE}>
{svg(<path d="M4 20h4L19 9l-4-4L4 16v4z" />)}
</button>
)}
{reordenavel && (
<>
<button type="button" data-foco="subir" onClick={() => mover(i, -1)} disabled={i === 0} aria-label={`${textos.subir}: ${item.texto}`} title={textos.subir} className={BOTAO_ICONE}>
{svg(<path d="M12 19V5m-6 6 6-6 6 6" />)}
</button>
<button
type="button"
data-foco="descer"
onClick={() => mover(i, 1)}
disabled={i === itens.length - 1}
aria-label={`${textos.descer}: ${item.texto}`}
title={textos.descer}
className={BOTAO_ICONE}
>
{svg(<path d="M12 5v14m-6-6 6 6 6-6" />)}
</button>
</>
)}
{editavel && (
<button
type="button"
onClick={() => remover(i)}
aria-label={`${textos.remover}: ${item.texto}`}
title={textos.remover}
className={`${BOTAO_BASE} hover:bg-critical/10 hover:text-critical`}
>
{svg(<path d="M6 6l12 12M18 6 6 18" />)}
</button>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { ListaDeTarefas, type ItemDaLista } from "./ListaDeTarefas";
const INICIAIS: ItemDaLista[] = [
{ id: "1", texto: "Levantar os números de vendas do trimestre", feito: true },
{ id: "2", texto: "Montar os gráficos do relatório", feito: true },
{ id: "3", texto: "Revisar o texto com a equipe de marketing", feito: false },
{ id: "4", texto: "Enviar para aprovação da diretoria", feito: false },
];
function Exemplo({ reordenavel, editavel, vazia }: { reordenavel: boolean; editavel: boolean; vazia: boolean }) {
const [itens, setItens] = useState<ItemDaLista[]>(vazia ? [] : INICIAIS);
return (
<div className="w-full max-w-xl">
<ListaDeTarefas itens={itens} aoMudar={setItens} reordenavel={reordenavel} editavel={editavel} rotulo="Etapas do relatório" />
</div>
);
}
export default defineStories({
args: { reordenavel: true, editavel: true, vazia: false },
controls: {
reordenavel: { type: "boolean" },
editavel: { type: "boolean", label: "editavel (adicionar, editar, remover)" },
vazia: { type: "boolean", label: "começar vazia" },
},
render: (a) => <Exemplo key={String(a.vazia)} {...a} />,
stories: [
{ name: "Checklist" },
{ name: "Só marcar", args: { editavel: false, reordenavel: false } },
{ name: "Vazia", args: { vazia: true } },
],
});
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.
Componentes da biblioteca (diretos e indiretos)
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);
}