O footer custom ('Registrar atualização' B42, 'Registrar movimento' E14) era
portalado da célula para o DrawerFooter do shared-sheet: createPortal preserva
o contexto React da origem (sem Drawer.Root), então o DrawerCloseButton
(base-ui DialogClose) crashava em useDialogRootContext() e o sheet nunca
renderizava.
O footer agora viaja no chrome do CampaignListSheetHost (mesmo mecanismo de
title/description) e é renderizado inline no DrawerFooter — contexto correto
por construção. O elemento continua criado na célula (estado do form e
associação form attribute preservados). footerPortalRef/attachFooterPortal/
hasCustomFooter removidos; fallback sem provider e popover intactos.
Cobertura: unit do shared path (abre/fecha/reabre + submit via form attribute)
e e2e mobile no describe B42 (abre, registra, fecha — incluindo o sheet do
nível E14).
6.4 KiB
Impl: Quick-edit sheet mobile da lista de municípios quebra ao abrir (DialogClose fora do contexto Drawer)
Status: aprovado Atualizado em: 2026-08-10 Issue: #540 (C109) Intenção: docs/plans/quick-edit-sheet-mobile-quebrado.md Appetite restante: herdado (~2–4 h eng)
Leitura da intenção
- Outcome: no celular, tocar em "Registrar atualização" (ou qualquer quick-edit sheet com footer custom) no card de município abre o sheet e registra, sem console error.
- O que NÃO negociar: popover desktop e fallback sem provider intactos; sem mudança de access/Consent/URL; affordance "Cancelar" no rodapé preservado (decisão B32).
- O que reavaliar: a hipótese da intenção (portal preserva contexto da origem). Confirmada no código:
CampaignCellEditOverlayportalizafooter(createPortal(footer, footerTarget)emCampaignCellEditOverlay.tsx:207) para oDrawerFooterdo host (CampaignListSheetHost.tsx:155);createPortalresolve contexto React da árvore de ORIGEM (a célula, semDrawer.Root), entãoDrawerCloseButton(base-uiDialogClose) explode emuseDialogRootContext(). O fallback sem provider renderiza o footer inline (contexto OK) — por isso o unit test atual passa.
Abordagem recomendada
flowchart LR
Cell["Célula (MunicipalityListUpdateControl / LevelControl)"] -->|openSheet chrome {title, description, footer}| Host["CampaignListSheetHost (Drawer.Root)"]
Cell -->|footer JSX criado aqui, estado do form vive aqui| Host
Host -->|renderiza chrome.footer inline no DrawerFooter| Footer["submit + Cancelar<br/>(DrawerClose resolve contexto do Drawer.Root)"]
Cell -->|body portalado (mantido)| Body["body no DrawerContent"]
Opções consideradas: A) footer vira parte do chrome do shared-sheet (mesmo mecanismo de title/description); a célula passa o footer no openSheet, o host renderiza dentro do DrawerFooter — portal do footer removido | B) envolver o footer portalado num wrapper de provider do Drawer (hack) | C) mover DrawerCloseButton para fora do footer custom (perder "Cancelar").
Recomendação: A — o ReactNode do footer é um elemento criado na célula (estado do form, useActionState, isPending continuam vivendo lá; a associação form attribute é DOM-level e não precisa de contexto), mas é renderizado na árvore do host, onde o Drawer.Root existe — contexto correto por construção, alinhado ao design existente do chrome, e remove o portal do footer por completo (sem timing de portalRevision para o footer).
Rejeitadas: B porque mascara o defeito com contexto duplicado e o primeiro crash só muda de endereço; C porque regride o affordance decidido em B32 (submit + Cancelar no rodapé de sheet com body rolável).
Componentes / mudanças
CampaignListSheetChrome(src/components/campaign/shared/CampaignListSheetHost.tsx): trocahasCustomFooter: booleanporfooter?: ReactNode(derivado:Boolean(chrome.footer)).DrawerFooterpassa a renderizar{chrome.footer ?? <DrawerCloseButton>Fechar</DrawerCloseButton>}— o div de portal (ref={attachFooterPortal}) sai;footerPortalRefeattachFooterPortalsaem do contexto e do provider (portalRevisionpermanece, servindo o body).openSheetincluicurrent.footer === next.footerna comparação de memo (na prática a célula sempre cria JSX fresco — comportamento equivalente ao de hoje, ondehandleOpenChangeda LevelControl já quebra o memo).CampaignCellEditOverlay.tsx(path shared,variant === 'sheet' && sharedSheet): para de portalar o footer —openSheet({ ..., footer, ... }); render retorna só trigger + liveRegion + body portalado. Path fallback (sem provider) e popover intactos.- Call sites (
MunicipalityListUpdateControl.tsx:83,MunicipalityListLevelControl.tsx:231): zero mudança de props — o footer continua sendo prop do overlay; os dois são os únicos call sites com footer custom no repo (verificado por grep), e os outros consumidores do provider (AdvisorsTable, dobradinhas, lideranças, trend/expected/leaderships/assessores) usam oFechardefault que o host já renderiza inline — não afetados. - Migration: sem migration (UI only).
- Access / Consent: nenhum.
- UI: Impeccable A — defect de render, restaura comportamento existente, sem redesenho.
Fases verificáveis
- Root cause em teste — unit test do shared path (provider + célula) que falha em
main(crash doDialogClose) e passa com o fix:tests/unit/campaignCellEditOverlay.unit.spec.tsganha describe do shared path cobrindoMunicipalityListUpdateControleMunicipalityListLevelControl(abre → footer com submit + "Cancelar" → fecha via "Cancelar" sem crash). - Fix no host/overlay — chrome com footer; overlay sem portal; remoção de
footerPortalRef/hasCustomFooter. - Gates —
pnpm gate:fast; e2e mobile: novo teste no describe B42 (tests/e2e/campaignMunicipalities.e2e.spec.ts) abre o sheet "Registrar atualização" no card (dialog visível, footer com "Registrar atualização" + "Cancelar", fecha) — regressão que vermelha em main; e2e existente do popover intacto. - Verificação manual no browser — abrir/registrar/fechar os dois sheets custom no viewport mobile.
Rabbit holes / Não escopo (engenharia)
- Redesenhar sheet/footer dos quick-edits.
- Outros drawers/shared-sheets (agenda, convites, notificações) — mecanismo próprio, fora.
- Refatorar a comparação de memo do
openSheet(comportamento já não-detectável hoje).
Riscos e mitigação
- E14 e B42 compartilham o path — o fix cobre ambos; unit + e2e cobrem os dois call sites.
- Reabertura do sheet —
dismissSheetzera o chrome e o próximoopenSheetrecria o footer fresco; unit test cobre abrir→fechar→reabrir. - Footer stale no host — o efeito do overlay re-envia o chrome a cada re-render da célula (dep
footerjá existe), entãoisPending/spinner chegam ao host;useLayoutEffectroda antes do paint. - Contexto do form — o submit do footer continua associado pelo
formattribute (DOM), como hoje; unit F5 existente garante a associação no path fallback.
Aceite de engenharia
- Quick-edit sheet com footer custom abre no shared path sem console error, e "Cancelar" fecha (unit + e2e mobile novos)
- Popover desktop e fallback sem provider intactos (unit existente + e2e existentes verdes)
- Invariantes: sem migration, sem access/Consent, sem mudança de props nos call sites