web-shared-ui
Como escrever primitivos de UI, hooks e libs compartilhadas em web/shared no Smaug — forwardRef só nos wrappers de elemento nativo, cn() + objeto clsx (não cva), wrappers Radix com JSDoc, blocos de la
SKILL.md.claude/skills/web-shared-ui/SKILL.md
GitHubshared/ — primitivos de UI, hooks e libs
Quando usar esta skill
Ao criar/alterar web/shared/components/*, web/shared/hooks/* ou web/shared/lib/*. Também ao
promover um componente de feature para compartilhado.
Primitivos de UI — shared/components/
Regra 1: forwardRef só nos wrappers de elemento nativo
Exatamente três componentes usam forwardRef — Button, Input, Select — porque precisam
funcionar com {...register()} do React Hook Form. Todos seguem a mesma forma:
export interface XProps extends XHTMLAttributes<HTMLXElement>, ...props espalhado por último,
e X.displayName = "X"; no fim.
shared/components/Button.tsx (arquivo completo):
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "../lib/utils";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant = "default", size = "md", isLoading, disabled, children, ...props },
ref,
) => {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-red focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
{
"bg-red text-white hover:bg-red/90": variant === "default" || variant === "danger",
"border border-border bg-surface hover:bg-bg": variant === "outline",
"hover:bg-bg": variant === "ghost",
},
{
"h-8 px-3 text-sm": size === "sm",
"h-10 px-4 text-base": size === "md",
"h-12 px-6 text-lg": size === "lg",
},
className,
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : null}
{children}
</button>
);
},
);
Button.displayName = "Button";
Os demais primitivos são export function X(props: XProps): Modal, ConfirmDialog, MonthYearSelect,
EmptyState, Skeleton, Tabs, TypeBadge, MonthNavigator, ToastContainer, AppShell,
Sidebar, BottomNav. ErrorBoundary é o único componente de classe.
O shell não conhece autenticação. AppShell e Sidebar recebem quem está logado e as ações de
saída por props; quem chama useAuth() é app/(app)/layout.tsx (skill web-arquitetura):
/** O que o menu mostra de quem está logado; `null` enquanto o perfil não chegou. */
export interface ShellUser {
name: string;
}
export interface SidebarProps {
user: ShellUser | null;
onLogout: () => void;
onLogoutAll: () => void;
className?: string;
}
AppShellProps é { user, onLogout, onLogoutAll, children } e repassa os três ao Sidebar
(desktop e overlay mobile). ShellUser é um tipo estrutural mínimo do próprio shared/ — não
importe User de @/features/auth/types. As iniciais caem em "?" enquanto user é null.
Regra 2: variantes por cn() + objeto clsx, não por cva
class-variance-authority está em package.json, mas não é importado por arquivo nenhum.
O mecanismo real de variante é o objeto clsx dentro do cn() (como no Button acima) ou um
Record<Uniao, string> no módulo:
const WIDTHS = {
sm: "max-w-sm",
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-[680px]",
"2xl": "max-w-[760px]",
};
Não introduza cva em componente novo.
Regra 3: vocabulário de props
variant: "default" | "outline" | "ghost" | "danger", size: "sm" | "md" | "lg",
width: "sm" | "md" | "lg" | "xl" | "2xl", layer: "base" | "top", booleanos com prefixo is (isLoading, isDanger, isOpen).
Props opcionais recebem default na desestruturação, com literal em pt-BR:
export function ConfirmDialog({
isOpen, onClose, onConfirm,
title = "Confirmar ação",
message,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
isDanger = false,
isLoading = false,
}: ConfirmDialogProps) {
ConfirmDialog compõe Modal em vez de reimplementar — é o padrão para diálogos novos.
Modal aceita os slots header (cabeçalho visível rico; title continua obrigatório como nome
acessível) e footer, e vira folha ancorada na base até 768 px. ConfirmDialog abre sempre com
layer="top" (acima de uma Ficha) e mostra ícone de lixeira quando isDanger. O "Esc fecha só a
confirmação, não a Ficha" vem do aninhamento de diálogos Radix — não trate Esc à mão.
Entrada/saída animadas de Modal e ToastContainer vêm da tw-animate-css (animate-in,
fade-in, zoom-in-95, slide-in-from-bottom-*…), sempre sob motion-safe: e, no Modal, por
data-[state=open|closed]: do Radix. Não escreva @keyframes próprios nem regra global de
movimento reduzido (congelaria animate-spin/animate-pulse).
Tela que abre Ficha/assistente não os monta só enquanto abertos: envolve-os em ModalGate
(shared/components/Modal.tsx) com isOpen, a origin do foco e uma key por abertura, como
DespesasScreen. Desmontar ao fechar mata a animação de saída. O Modal devolve o foco a quem o
abriu — não trate foco à mão.
Regra 4: Radix com JSDoc justificando
@radix-ui/react-dialog e @radix-ui/react-tabs sustentam Modal e Tabs, importados em
namespace (import * as Dialog from "@radix-ui/react-dialog"). Todo wrapper carrega um JSDoc em
português explicando por que o Radix substituiu a versão manual:
/**
* Apoiado no Radix Dialog: ele entrega focus trap, devolução do foco ao fechar,
* Escape, trava de scroll e a associação do título via aria-labelledby — tudo o
* que a versão manual não tinha (o foco escapava para a página atrás).
*/
O Radix exige título acessível mesmo sem cabeçalho visível — daí o
<Dialog.Title className="sr-only">Janela</Dialog.Title> no ramo sem title.
Regra 5: blocos de lançamentos em shared/components/lancamentos/
Receitas e Despesas compartilham extrato, assistente e Ficha. As peças comuns ficam aqui,
apresentacionais e tipadas sem tipo de feature (só Competence, EntryKind, Tone,
LedgerRow<T> de shared/lib):
| Grupo | Componentes |
|---|---|
| Extrato | LedgerLayout, MonthHeader, MonthStrip, MonthSummary, EntryGroup, EntryRow, LedgerEmptyState, ErrorNotice |
| Assistente | WizardShell, StepIndicator, ChoiceCard(Group), MiniGrid, MoneyField, Stepper, CompetenceSelect, AffectedMonths |
| Ficha | OneTimeFicha, SeriesFicha, FichaPlaceholder, EntryDetailHeader, FactGrid, ProgressBar, MonthCells, Legend, VersionChips, VersionHistoryList |
- O que varia entre as naturezas entra por prop: textos (
noun,emptyHint),tone("expense"vermelho /"revenue"verde) echildrenpara os modais. - Estado permitido aqui é só de UI (
useId, confirmação aberta). Dados e mutations chegam prontos:SeriesFicharecebe oSeriesActionsdeuseSeriesActionscriado pela Ficha da feature. - Cor, ícone e rótulo de cada tipo vêm de
ENTRY_KIND(shared/lib/entry-types.ts);TypeBadgeusa o mesmo mapa, inclusive no Dashboard.
Quando promover um componente para shared/
Quando duas features precisarem dele. Ele entra em shared/components/, com props genéricas
(sem tipo de feature) e sem importar nada de features/.
shared/hooks/
useToast.ts— pub/sub fora do React (toastListeners/toastQueueno módulo) maisuseToastState()para assinar. O consumidor importa o objetotoast, não um hook:import { toast } from "@/shared/hooks/useToast";— é isso que permite disparar de dentro deonSuccess/onErrorde uma mutation.
O shapeexport interface Toast { id: string; type: "success" | "error" | "info" | "warning"; message: string; action?: { label: string; onClick: () => void }; duration?: number; } export const toast = { show, success, error, info, warning };action?: { label, onClick }é o idioma da casa para "CTA opcional" e reaparece emEmptyStateProps.useMediaQuery.ts—useMediaQuery(query: string): booleaneuseIsMobile()("(max-width: 768px)").useDebounce.ts—useDebounce<T>(value: T, delay = 500): T.useCompetenceNavigation.ts— navegação por mês do Dashboard, Receitas e Despesas;persistKeyopcional grava emsessionStorage(comtry/catch, lido poruseSyncExternalStorepara não quebrar a hidratação).useLedgerNavigation()é a versão das telas de lançamentos (chaveLEDGER_COMPETENCE_KEY,adoptReference).useSeriesActions.ts— modo, abas, formulários e prévia da Ficha de série; recebe os schemas da feature por parâmetro.
shared/lib/
Um arquivo por função ou por conceito, sem barril:
| Arquivo | API |
|---|---|
utils.ts |
cn(...inputs: ClassValue[]) = twMerge(clsx(inputs)) |
formatCurrency.ts |
formatCurrency(value: number): string (Intl pt-BR/BRL) |
money.ts |
parseMoneyInput (texto → número, NaN para lixo), isValidMoneyInput, toMoneyInput, formatCompactCurrency ("3.420") |
competence.ts |
Competence, CompetenceStatus, ApiCompetenceStatus, fromApiStatus, sameCompetence, compareCompetences, addMonths, diffMonths, monthRange, getCurrentCompetence, getCompetenceStatus, selectableYears, formatCompetenceShort ("Mai/26"), formatCompetenceLong ("Maio 2026") |
preview.ts |
splitInstallments, affectedMonths, seriesSpan, seriesTimeline — prévias puras que espelham o domínio do servidor |
ledger.ts |
LedgerRow<T>, LedgerGroup<T>, groupRows, totalLabel, seriesPeriod, seriesRange, buildStripCells |
entry-types.ts |
EntryKind, ENTRY_KIND (cor, classe do selo, ícone), versionTint, Tone, TONE_TEXT |
dateUtils.ts |
MONTH_NAMES_FULL, formatMonthYear(year, month) → "Set/26" |
type-guards.ts |
isString, isNumber, isObject, hasProperty, isApiError |
Regra que atravessa todo o shared/lib: "agora" é um parâmetro opcional com default, nunca lido
implicitamente.
export function getCompetenceStatus(
competence: Competence,
reference: Competence = getCurrentCompetence(),
): CompetenceStatus;
export function selectableYears(reference: Competence = getCurrentCompetence()): number[];
Em Receitas e Despesas o reference vem do servidor (campo reference das respostas) e é passado
explicitamente; o default local fica para o Dashboard e o MonthYearSelect da área de Dados.
É o que torna essas funções 100% testáveis sem fake timers — e o web/vitest.config.ts exige
exatamente 100% de cobertura em shared/lib/**.
parseMoneyInput (em money.ts) é o exemplar do padrão de comentário do projeto: JSDoc citando o
bug que ele substituiu (parseFloat(v.replace(",", ".")) transformando "1.234,56" em 1.234).
Imite esse estilo quando escrever uma lib nova.
Checklist
-
forwardRef+displayNameapenas se embrulhar elemento de formulário nativo. - Variante por
cn()+ objetoclsxouRecord<Uniao, string>; nuncacva. - Defaults de props em pt-BR na desestruturação.
- Wrapper de biblioteca com JSDoc em pt-BR justificando a escolha.
- Nada importado de
features/. - Função de lib com "agora" como parâmetro
referencedefault. - Teste ao lado, cobrindo os thresholds de
shared/**emweb/vitest.config.ts.
Não faça
- Não use
cva. - Não importe de
@/features/...dentro deshared/— nem hook, nem tipo. Precisa de dado de uma feature? Declare o tipo mínimo emshared/e receba por prop, comoShellUsernoSidebar. - Não leia
new Date()dentro de uma função deshared/lib. - Não crie
shared/lib/index.tsnemshared/components/index.ts. - Não use
shared/types/index.tscomo destino de tipo novo: ele já está morto (nada o importa) e duplicaToasteApiError.