feat(C107): sheet de criação de atualização usável no mobile — altura auto até o topo, form sem labels com divisórias full-bleed, placeholders, polaridade em toggle, checks à direita

- MunicipalityUpdateFields ganha layout 'labeled'|'list'; polaridade vira ToggleGroup Ruim|Neutra|Boa nas duas resoluções (mesmo dado, hidden input)
- layout 'list': labels sr-only, textarea sem borda, divisórias divide-y full-bleed, Urgente/Sinalizar adversário como linha tocável com check à direita
- StrictCombobox: prop placeholder (backward-compatible)
- Sheet do feed: max-h-dvh (teto no topo, rola por dentro), título sr-only, form lista com placeholders descritivos; desktop preservado
- Quick-edit B42 herda o visual no sheet mobile via layout
- unit/e2e atualizados para o toggle (radio)
This commit is contained in:
Francisco Solla 2026-08-10 00:43:34 -03:00
parent 8896dcb952
commit 4e920ee064
8 changed files with 397 additions and 76 deletions

View file

@ -0,0 +1,100 @@
# Impl: Atualizações mobile — sheet de criação usável (altura auto até o topo, form sem labels com divisórias, placeholders, polaridade em toggle, checks à direita)
Status: aprovado
Atualizado em: 2026-08-09
Issue: #519
Intenção: docs/plans/atualizacoes-mobile-sheet-criacao-usavel.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** no celular, o sheet de criação do feed abre com altura = conteúdo (teto no topo da tela; rola por dentro só quando estoura), sem título visível, form estilo lista sem labels/bordas com divisórias full-bleed, placeholders descritivos, polaridade em toggle Ruim|Neutra|Boa e checks à direita com linha inteira tocável — registrar sem lutar com o sheet.
- **O que NÃO negociar:** dados gravados inalterados (`body`, `polarity boa|neutra|ruim` default `neutra`, `urgent`, `adversarySignal`); anti-goal "não virar overlay de tela cheia nem segunda página"; desktop do feed mantém título/labels/descrições salvo o decidido no gate sobre campos compartilhados; acessibilidade mantida (labels acessíveis, erros anunciados, obrigatórios marcados).
- **O que reavaliar:** a hipótese apontava possível mudança na primitiva `Drawer` — verificado: a primitiva já é auto-height (`--drawer-content-height:auto` + teto `--drawer-content-max-height`), o uso atual só impõe `max-h-[90dvh]`; a mudança cabe 100% no uso, sem tocar a primitiva. C103 não tem implementação em `main` (branch local = só o plano) — este item estabelece o padrão, C103 reutilizará.
## Abordagem recomendada
```mermaid
flowchart LR
A[MunicipalityUpdateFields] -->|prop layout: 'labeled'\|'list'| B[Campos compartilhados]
B --> C[feed create modal]
B --> D[quick-edit lista B42]
C --> E[mobile: sheet auto até topo + form lista]
C --> F[desktop: dialog com labels]
D --> G[sheet: herda lista]
D --> H[popover: labels]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — variante de apresentação dentro do componente de campos compartilhados (`layout`), controle de polaridade vira `ToggleGroup` nas duas resoluções, altura do sheet ajustada por override scoped no uso (`max-h-dvh`), primitiva `Drawer` intocada.
**Rejeitadas:** B) primitiva do Drawer ganha modo "topo" — blast radius em `CalendarFeedDialog` e demais superfícies sem volatilidade que justifique; C) componentes duplicados (fields do feed e fields da lista) — viola "edit the owner, don't twin".
### Componentes / mudanças
- **`MunicipalityUpdateFields`** (`src/components/campaign/municipality/MunicipalityUpdateFields.tsx`): ganha `layout?: 'labeled' | 'list'` (default `labeled` = hoje).
- Ambas os layouts: `NativeSelect` → `ToggleGroup` (`ToggleGroupItem` Ruim|Neutra|Boa, ordem pedida; `variant="outline"` + `spacing={0}` com `data-[state=on]:bg-primary data-[state=on]:text-primary-foreground` = destacado; precedente `VoteIntentionControl`); valor submetido via `<input type="hidden" name="polarity" value={polarity} />` (mesmo nome/valores; select não submete button). Grupo com `aria-labelledby` apontando para o `FieldLabel` (visível no labeled, `sr-only` no list) → `getByLabel('Polaridade')` continua funcionando.
- `layout="list"`: container `flex flex-col divide-y divide-border` (full-bleed — sem padding horizontal no container; cada linha `px-4`); rows: texto (label sr-only; `Textarea` sem borda `border-0 bg-transparent rounded-none px-0`, placeholder `"Descrever o que aconteceu..."`, `required` mantido, `FieldError` mantido), polaridade (toggle w-full), Urgente e Sinalizar adversário (linhas `<label htmlFor>` — button é labelable — `flex min-h-11 items-center justify-between gap-3 px-4` com `<Checkbox>` à direita; descrições removidas; hidden `false` preservado; linha inteira tocável por label activation nativo).
- **`StrictCombobox`** (`src/components/campaign/shared/StrictCombobox.tsx`): `placeholder?: string` pass-through ao `ComboboxInput` (backward-compatible).
- **`CampaignUpdatesCreateModal`** (`src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx`):
- Mobile: `DrawerContent` `max-h-[90dvh]` → `max-h-dvh` (teto no topo; auto-height já é o comportamento da primitiva); `DrawerTitle` → `sr-only` (nome acessível preservado); container de scroll sem `px-4` (linhas com `px-4` para divisórias full-bleed); form em layout lista: linha do município (label `sr-only`, `StrictCombobox` `placeholder="Adicionar município"` + `border-0 bg-transparent rounded-none w-full`, erro mantido), divisória explícita `border-t border-border`, `MunicipalityUpdateFields layout="list"`, divisória, linha de botões (`px-4 pt-4`, mesmos botões).
- Desktop: inalterado (título, labels), só o toggle vindo dos campos compartilhados.
- **`MunicipalityListUpdateControl`** (`src/components/campaign/municipality/MunicipalityListUpdateControl.tsx`): `layout={isSheet ? 'list' : 'labeled'}` — o quick-edit herda o visual no sheet mobile (decisão do gate: compartilhar), popover desktop mantém labels. Chrome do overlay (título "Registrar atualização" + nome do município) intocado.
- **Migration:** sem migration (apresentação pura; nenhum campo/schema muda).
- **Access / Consent:** nenhuma mudança (mesmo form action, mesmos names).
- **UI:** Impeccable B — encaixe na superfície existente; shape → craft → critique → polish conforme canvas plan-c107-ui-draft (linha do município ~44px, toggle pill com célula selecionada em acento, rows de check com texto à esquerda / quadrado à direita, botões sob a última divisória).
### Dados → forma (se aplicável)
- N/A — `Dados: N/A` herdado da intenção (affordance de escrita, nenhum dado/KPI/mapa).
## Decisões de engenharia
| Decisão | Opções | Recomendação | Rejeitadas |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Escopo do visual "sem labels/bordas/divisórias" | A) mobile-only (feed sheet + quick-edit sheet) | **A** — desktop mantém labels/descrições (descoberta; precedente C103); o componente compartilhado recebe `layout` e cada superfície escolhe | B) as duas resoluções sem labels — desktop perde descoberta sem ganho de espaço |
| Toggle de polaridade | A) só mobile | **B — nas duas resoluções** (intenção, questão aberta B) — um controle só para o campo; padrão segmentado já existe (`VoteIntentionControl`) | A) mobile-only — duas superfícies divergentes para o mesmo campo; C) manter select — controle pesado para 3 valores |
| Quick-edit (B42) herda? | A) herda via componente compartilhado | **A** — mesmo componente, mesma pessoa/job "registrar o que aconteceu"; zero duplicação | B) escopar ao feed — segunda apresentação do mesmo form |
| Altura/posição do sheet | A) override no uso (`max-h-dvh` + auto-height existente) | **A** — primitiva intocada, backward-compatible | B) novo modo na primitiva `Drawer` — blast radius desnecessário (CalendarFeedDialog etc.) |
| Linha de check tocável | A) `<label htmlFor>` envolvendo o `Checkbox` (button é labelable; label activation dispara o clique) | **A** — sem estado extra, sem elemento duplicado; o bubble input do Radix mantém o submit (`name`/`value` inalterados) | B) checkbox oculto + div custom — perde semântica de checkbox e o `getByLabel` do e2e; C) onClick manual com estado — dois controles de estado para o mesmo dado |
| Ordem do toggle | A) Ruim \| Neutra \| Boa | **A** — pedido explícito; neutra central como default | B) Boa \| Neutra \| Ruim — ordem do select de hoje, contraria o pedido |
| Acessibilidade do grupo | `aria-labelledby` → FieldLabel (sr-only no list) | `getByLabel('Polaridade')` continua casando; label visível só no labeled | `aria-label` hardcoded — duplica fonte do nome |
## Fases verificáveis
1. **Tracer / campos compartilhados** — `MunicipalityUpdateFields` (`layout` + ToggleGroup + hidden polarity + rows de check à direita), `StrictCombobox` placeholder, `MunicipalityListUpdateControl` wiring; atualizar e2e do popover (`campaignMunicipalities.e2e.spec.ts` ~L598: `selectOption('ruim')` → clique no toggle `Ruim` do grupo `getByLabel('Polaridade')`).
2. **Sheet do feed (mobile)** — `CampaignUpdatesCreateModal`: `max-h-dvh`, título sr-only, form lista com divisórias full-bleed, placeholders; desktop intacto.
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não `git push` nu); PR `--base main` + `Closes #519` + auto-merge; `gh pr checks --watch --required`.
## Rabbit holes / Não escopo (engenharia)
- **Wizard `/campanha/acoes/registrar-atualizacao` e aba updates do detalhe** (`MunicipalityUpdateForm` — cópia própria com select e labels): fora de escopo, intocado. Divergência conhecida (select lá, toggle aqui) → defer com gatilho: se a mesa pedir consistência total de "Registrar atualização", unificar os campos do wizard no componente compartilhado.
- **Trigger de criar** (omnibox hoje, header no mobile) → C106 (#517): este item não toca o trigger.
- **C103 (agenda)** — padrão irmão em paralelo; sem componente compartilhado com este domínio (fields de atividade ≠ fields de atualização); nenhuma coordenação de código necessária. C103 reutiliza o padrão de `max-h-dvh` + rows full-bleed quando executar.
- **Redesenho geral do `Drawer` / outras listas / paleta** — fora.
- **e2e novo para o sheet mobile** — não adicionar: a mudança é apresentação pura (mesmo form action), e altura de sheet auto é frágil de afirmar em e2e; o fluxo de dados já é coberto pelo e2e do popover atualizado.
## Riscos e mitigação
- **`getByLabel('Polaridade')` no e2e do popover** (L598) quebra com o toggle → mitigação: atualizar o spec para clique no toggle no mesmo teste (já planejado, fase 1). ✓ verificado: `getByLabel` casa o grupo via `aria-labelledby` (unit + e2e + browser real).
- **`aria-labelledby` do ToggleGroup** não formar nome acessível em algum browser → fallback `aria-label="Polaridade"` no grupo, mantendo o label visual. ✓ verificado no browser real (getByLabel → grupo; radios nomeados).
- **Label envolvendo o Checkbox** (Radix button) — se label activation não disparar em algum browser, substituir por `<button type="button">` com `onClick` no estado controlado (fallback). ✓ verificado no browser real (clique no texto "Urgente" alterna o check).
- **Deseleção do ToggleGroup** (Radix `type="single"` emite `''` ao re-clicar o item ativo) → no-op no `onValueChange` (`if (value)`), comportamento de radio; sem estado fora do domínio.
- **Mensagem stale ao reabrir** (`useActionState` persiste) → guard `state.status !== 'success' && state.message` nos dois forms (unificados); sucesso fecha via toast.
- **Conflito de branch/worktree com C106/C103** (mesma família de superfície) — mudanças scoped por arquivo; C106 não toca o modal; C103 não toca atualizações.
- **`max-h-dvh` com teclado aberto** — dvh = viewport dinâmico (teclado excluído): teto continua no topo da área visível; rolagem interna é o aceite ("rola por dentro"). ✓ verificado: conteúdo curto = sheet auto (420px ancorado); conteúdo longo = topo 0 + scroll interno.
## Notas pós-/simplify (2026-08-10)
- Form do modal unificado (um `<form>` com chrome condicional por `isMobile`) — removeu a duplicação e a divergência de guard de mensagem.
- `CheckRow` extraído em `MunicipalityUpdateFields` (4 spellings do par hidden-false + Checkbox → 1).
- `sheetBodyClassName="px-0 pt-0"` no quick-edit sheet (divisórias full-bleed também lá).
- `htmlFor` de polaridade removido (alvo não-labelable; nome acessível via `aria-labelledby`).
- `focus-visible:ring-0` removido do textarea list (anel de foco mantido — WCAG 2.4.7).
- Bug pré-existente registrado: quick-edit sheet dos cards mobile de `/campanha/municipios` quebra ao abrir (`DialogClose` fora do contexto Drawer, shared-sheet footer) — reproduz em `main` puro, fora do escopo C107. **→ Issue C109 (#540, defect P1, plano curto).**
- Defer com gatilho (triage capture-review-debts): (a) estilo `accent` do ToggleGroup centralizado em `Toggle.tsx` quando houver 3º uso com destaque em acento (hoje: VoteIntentionControl/SupporterForm `bg-muted` + C107 `bg-primary`); (b) swipe handle no topo quando o sheet atinge `dvh` — item PWA/fullscreen ou queixa real (env safe-area top é position-dependent).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (altura auto até o topo, sem título no mobile, form sem labels com divisórias full-bleed, placeholders, toggle Ruim|Neutra|Boa com default Neutra, checks à direita linha tocável, dados inalterados, desktop preservado exceto toggle)
- [ ] Invariantes AGENTS/engineering-standards (identificadores em inglês, copy pt-BR, sem migration, sem acesso/Consent)
- [ ] Testes de domínio: e2e do quick-edit popover atualizado para o toggle (interação de polarity); demais e2e de campos (wizard, detalhe) intocados e verdes

View file

@ -0,0 +1,72 @@
# Impl: Quick-edit sheet mobile da lista de municípios quebra ao abrir (DialogClose fora do contexto Drawer)
Status: registrado
Atualizado em: 2026-08-10
Issue: (a registrar — C109)
Priority: P1
Model: composer-2.5
Model-local: deepseek-v4-flash-high
Impeccable: A — defect de render, sem superfície nova
Appetite: ~2–4 h eng; um outcome verificável — no celular, tocar em "Registrar atualização" (ou qualquer quick-edit sheet) no card de município abre o sheet e registra sem console error.
## Intenção (defect)
No mobile, os quick-edits em sheet dos cards de `/campanha/municipios` (B42: `MunicipalityListUpdateControl` variant `sheet`, E14 "Registrar movimento", etc.) **não abrem**: o clique dispara o erro `TypeError: Cannot destructure property 'store' of useDialogRootContext() as it is undefined` em `DialogClose` (base-ui), capturado pelo error boundary — o sheet nunca renderiza.
**Evidência:** reproduz em `main` puro (commit 8896dcb9, stash testado em 2026-08-10); console do browser no dev server; `[data-slot="drawer-popup"]` ausente após o clique; `role="dialog"` ausente.
## Root cause (teorizado)
O footer custom do `CampaignCellEditOverlay` variant `sheet` com `CampaignListSheetProvider` é renderizado na árvore da **célula** (`MunicipalityListUpdateControl` — sem `Drawer.Root` ancestral) e enviado via `createPortal(footer, footerTarget)` para o `DrawerFooter` do shared-sheet. `createPortal` **preserva o contexto React da origem** (a célula), não o do alvo — então o `DrawerCloseButton` (`DrawerClose` → base-ui `DialogClose`) dentro do footer custom resolve `useDialogRootContext()` como `undefined` → crash.
O fallback sem provider (um `Drawer` por célula) renderiza o footer **dentro** do `DrawerContent` — contexto OK; é por isso que o unit test B32+ F5 (`campaignCellEditOverlay.unit.spec.ts`) passa e o shared path quebra.
## Abordagem recomendada
**Opções:** A) renderizar o footer custom no host (não portalado da célula) — o `CampaignListSheetHost` ganha um slot de footer por estado (ex.: contexto com `footer: ReactNode` setado via `openSheet`, como já faz com `chrome.title/description`), e a célula para de portalar o footer | B) envolver o footer portalado num `Drawer`-provider wrapper (hack) | C) mover `DrawerCloseButton` para fora do footer custom (degradar o affordance de fechar).
**Recomendação:** A — o footer vira parte do `chrome` do shared-sheet (mesmo mecanismo do título/descrição); a célula envia o footer como valor no `openSheet` e o host renderiza dentro do `DrawerFooter` (contexto correto). O fallback sem provider continua como está. Alinha com o design existente (`hasCustomFooter` já existe no chrome) e remove o portal do footer por completo.
**Rejeitadas:** B porque mascara o problema e adiciona contexto duplicado; C porque remove o botão "Cancelar" do rodapé (regressão de affordance — B32 decidiu footer custom com submit).
### Componentes / mudanças
- **`CampaignListSheetHost.tsx`**: `CampaignListSheetChrome` ganha `footer?: ReactNode`; `openSheet` aceita o footer; `DrawerFooter` renderiza `chrome.footer` (além do `DrawerCloseButton` default quando `!hasCustomFooter`); o ref do portal de footer pode ser removido ou mantido vazio (verificar call sites).
- **`CampaignCellEditOverlay.tsx`**: para de portalar o footer no shared path — passa `footer` no `openSheet({ ... footer })`; mantém o portal/path de fallback.
- **Call sites com footer custom**: `MunicipalityListUpdateControl` (B42) e `MunicipalityListLevelControl` (E14 "Registrar movimento") — sem mudança de props (o footer continua sendo passado como prop do overlay).
- **Sem migration / access / Consent.**
## Fases verificáveis
1. **Root cause confirmado no browser** — reproduzir com o fix mínimo; verificar que `DialogClose` resolve o contexto.
2. **Fix no host/overlay** — mover o footer para o chrome; remover portal.
3. **Gates** — `pnpm gate:fast`; verificação manual no browser mobile (abrir quick-edit sheet de município, registrar, fechar); e2e do `campaignMunicipalities` (popover intocado) + unit `campaignCellEditOverlay`.
4. **Cobertura nova (opcional mas recomendada)**: um unit test do shared path (renderizar `CampaignListSheetHost` + célula) para o footer custom — hoje o bug não é coberto por nenhum teste.
## Rabbit holes / Não escopo
- Redesenhar o sheet/footer dos quick-edits (o fix restaura o comportamento existente, sem redesenho).
- Outros drawers/shared-sheets (agenda, convites) — fora.
- O crash afeta TODOS os footers custom no shared path — verificar os call sites antes de fechar.
## Riscos e mitigação
- **E14 "Registrar movimento"** usa o mesmo shared path com footer custom — o fix cobre os dois; verificar ambos no browser.
- **Remover o portal pode quebrar o `portalRevision`/timing** do host — manter o `portalRevision` para o body; testar reabertura.
- **`chrome.footer` como ReactNode** pode carregar contexto da célula (ex.: `useActionState` do submit) — o footer continua sendo CRIADO na célula (só renderizado no host), então o contexto de estado do form é preservado; o que muda é apenas o `Drawer`-provider, que passa a estar presente.
## Aceite de engenharia
- [ ] Quick-edit sheet abre no mobile sem console error e registra (verificado no browser)
- [ ] Unit test do shared path cobre o footer custom (novo) ou fallback documentado
- [ ] Popover desktop e fallback sem provider intactos; e2e/unit verdes
## Já resolvido no simplify/critique (não reabrir)
- N/A (defect detectado na verificação manual do C107, fora do escopo daquela entrega).
## Explicitamente fora (deste triage)
- Swipe handle no topo quando o sheet atinge `dvh` (C107, defer com gatilho: item PWA/fullscreen).
- Estilo `accent` do ToggleGroup vs precedentes (C107, defer com gatilho: 3º uso).
- Wizard `/acoes/registrar-atualizacao` com select vs toggle (C107, defer com gatilho: consistência pedida pela mesa).

View file

@ -14,6 +14,7 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui
import { Spinner } from '@/components/ui/Spinner'
import { useIsMobile } from '@/hooks/use-mobile'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { cn } from '@/lib/utils'
import { fieldError } from '@/utilities/campaignFormFields'
import type { CampaignUpdatesFeedFacets } from '@/utilities/municipality/campaignUpdatesFeedData'
@ -70,41 +71,78 @@ export const CampaignUpdatesCreateModal = ({
const municipalityError = fieldError(state.fieldErrors, 'municipalityId')
// Mobile sheet: list-style form — no visible labels/borders, full-bleed
// divider rows, descriptive placeholders (C107). The label stays for the
// accessible name; the sheet auto-sizes to the content with its ceiling at
// the top of the screen (`max-h-dvh`), scrolling internally only when the
// content outgrows the viewport (e.g. open keyboard). Desktop keeps the
// labeled dialog chrome.
const createForm = (
<form action={submitAction} className="flex flex-col gap-4">
<form action={submitAction} className={cn('flex flex-col', !isMobile && 'gap-4')}>
<input type="hidden" name="municipalityId" value={municipalityId} />
<Field>
<FieldLabel htmlFor={MUNICIPALITY_COMBOBOX_ID}>Município</FieldLabel>
<FieldDescription>
O registro fica vinculado a um município da sua carteira.
</FieldDescription>
<StrictCombobox
id={MUNICIPALITY_COMBOBOX_ID}
options={options}
value={municipalityId}
onValueChange={setMunicipalityId}
error={municipalityError}
/>
{municipalityError ? (
<FieldError id={`${MUNICIPALITY_COMBOBOX_ID}-error`}>{municipalityError}</FieldError>
) : null}
</Field>
{isMobile ? (
<>
<div className="px-4 py-1">
<FieldLabel htmlFor={MUNICIPALITY_COMBOBOX_ID} className="sr-only">
Município
</FieldLabel>
<StrictCombobox
id={MUNICIPALITY_COMBOBOX_ID}
options={options}
value={municipalityId}
onValueChange={setMunicipalityId}
error={municipalityError}
placeholder="Adicionar município"
className="min-h-11 w-full rounded-none border-0 bg-transparent shadow-none dark:bg-transparent"
/>
{municipalityError ? (
<FieldError id={`${MUNICIPALITY_COMBOBOX_ID}-error`}>{municipalityError}</FieldError>
) : null}
</div>
<div className="border-t border-border" />
</>
) : (
<Field>
<FieldLabel htmlFor={MUNICIPALITY_COMBOBOX_ID}>Município</FieldLabel>
<FieldDescription>
O registro fica vinculado a um município da sua carteira.
</FieldDescription>
<StrictCombobox
id={MUNICIPALITY_COMBOBOX_ID}
options={options}
value={municipalityId}
onValueChange={setMunicipalityId}
error={municipalityError}
/>
{municipalityError ? (
<FieldError id={`${MUNICIPALITY_COMBOBOX_ID}-error`}>{municipalityError}</FieldError>
) : null}
</Field>
)}
<MunicipalityUpdateFields
idPrefix="campaign-updates-create"
fieldErrors={state.fieldErrors}
isStaff={isStaff}
layout={isMobile ? 'list' : 'labeled'}
/>
{state.status !== 'success' ? (
<CampaignFormActionMessage
state={state}
errorTitle="Não foi possível registrar"
successFallbackMessage="Atualização registrada."
/>
{state.status !== 'success' && state.message ? (
<div className={cn(isMobile && 'border-t border-border px-4 pt-3')}>
<CampaignFormActionMessage
state={state}
errorTitle="Não foi possível registrar"
successFallbackMessage="Atualização registrada."
/>
</div>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
isMobile && 'border-t border-border px-4 pt-4 pb-1',
)}
>
<Button
type="button"
variant="outline"
@ -125,12 +163,12 @@ export const CampaignUpdatesCreateModal = ({
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>
<DrawerContent className="max-h-[90dvh] border-t border-border bg-background text-foreground">
<DrawerTitle className="mb-3 text-base font-medium">Nova atualização</DrawerTitle>
<DrawerContent className="max-h-dvh border-t border-border bg-background text-foreground">
<DrawerTitle className="sr-only">Nova atualização</DrawerTitle>
<DrawerDescription className="sr-only">
Registre um novo fato de campo em um município
</DrawerDescription>
<div className="flex flex-col overflow-y-auto px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3">
<div className="flex flex-col overflow-y-auto pb-[max(1rem,env(safe-area-inset-bottom))]">
{createForm}
</div>
</DrawerContent>

View file

@ -12,6 +12,7 @@ import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCam
import { Button } from '@/components/ui/button'
import { DrawerCloseButton } from '@/components/ui/Drawer'
import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatMunicipalitySignalAgeLabel,
@ -77,6 +78,7 @@ export const MunicipalityListUpdateControl = ({
statusMessage={isPending ? 'Registrando atualização…' : ''}
triggerClassName="min-w-11 text-left"
contentClassName="w-80"
sheetBodyClassName={isSheet ? 'px-0 pt-0' : undefined}
trigger={children}
footer={
isSheet && open ? (
@ -91,7 +93,7 @@ export const MunicipalityListUpdateControl = ({
key={formKey}
id={formId}
action={submitAction}
className="flex flex-col gap-3"
className={cn('flex flex-col', !isSheet && 'gap-3')}
aria-busy={isPending || undefined}
>
<input type="hidden" name="municipalityId" value={municipalityID} />
@ -100,8 +102,13 @@ export const MunicipalityListUpdateControl = ({
idPrefix={idPrefix}
fieldErrors={state.fieldErrors}
isStaff={isStaff}
layout={isSheet ? 'list' : 'labeled'}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
{state.status !== 'success' && state.message ? (
<div className={cn(isSheet && 'px-4 pt-3')}>
<CampaignFormActionMessage state={state} />
</div>
) : null}
{isSheet ? null : submitButton}
</form>
</CampaignCellEditOverlay>

View file

@ -4,8 +4,8 @@ import { useState } from 'react'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import {
municipalityUpdatePolarityLabels,
type MunicipalityUpdatePolarity,
@ -16,18 +16,141 @@ type MunicipalityUpdateFieldsProps = {
idPrefix: string
fieldErrors?: Record<string, string[]>
isStaff: boolean
/**
* 'labeled' (desktop): visible labels, descriptions and bordered fields.
* 'list' (mobile): visually hidden labels, no borders, full-bleed divider
* rows and right-aligned check boxes (C107 — the field is the same, only
* the presentation varies).
*/
layout?: 'labeled' | 'list'
}
const POLARITY_OPTIONS = ['ruim', 'neutra', 'boa'] as const
/**
* One Urgente/Sinalizar adversário row. In the list layout the row is a
* `<label>` around the Radix Checkbox (a `<button>` — labelable), so the whole
* line is a tap target and the square sits on the right; the hidden "false"
* input lives OUTSIDE the label so label activation never re-checks it.
*/
const CheckRow = ({
id,
name,
label,
description,
isList,
}: {
id: string
name: string
label: string
description?: string
isList: boolean
}) => {
if (isList) {
return (
<div className="px-4">
<input type="hidden" name={name} value="false" />
<label
htmlFor={id}
className="flex min-h-11 cursor-pointer items-center justify-between gap-3"
>
<span>{label}</span>
<Checkbox id={id} name={name} value="true" />
</label>
</div>
)
}
return (
<div className="flex items-start gap-3">
<input type="hidden" name={name} value="false" />
<Checkbox id={id} name={name} value="true" className="mt-0.5" />
<div className="flex flex-col gap-1">
<FieldLabel htmlFor={id} className="font-normal">
{label}
</FieldLabel>
{description ? <FieldDescription>{description}</FieldDescription> : null}
</div>
</div>
)
}
export const MunicipalityUpdateFields = ({
idPrefix,
fieldErrors,
isStaff,
layout = 'labeled',
}: MunicipalityUpdateFieldsProps) => {
const [polarity, setPolarity] = useState<MunicipalityUpdatePolarity>('neutra')
const bodyId = `${idPrefix}-body`
const polarityId = `${idPrefix}-polarity`
const polarityLabelId = `${idPrefix}-polarity-label`
const urgentId = `${idPrefix}-urgent`
const adversaryId = `${idPrefix}-adversary`
const isList = layout === 'list'
const bodyError = fieldError(fieldErrors, 'body')
const polarityError = fieldError(fieldErrors, 'polarity')
const polarityControl = (
<ToggleGroup
id={polarityId}
type="single"
value={polarity}
// Radix single emits '' when the selected item is clicked again; a
// required 3-value field cannot be deselected, so '' is a no-op.
onValueChange={(value) => {
if (value) setPolarity(value as MunicipalityUpdatePolarity)
}}
variant="outline"
spacing={0}
aria-labelledby={polarityLabelId}
className="w-full"
>
{POLARITY_OPTIONS.map((option) => (
<ToggleGroupItem
key={option}
value={option}
className="flex-1 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary data-[state=on]:hover:text-primary-foreground"
>
{municipalityUpdatePolarityLabels[option]}
</ToggleGroupItem>
))}
</ToggleGroup>
)
if (isList) {
return (
<div className="flex flex-col divide-y divide-border">
<div className="px-4 pt-3 pb-2">
<FieldLabel htmlFor={bodyId} className="sr-only">
Texto
</FieldLabel>
<Textarea
id={bodyId}
name="body"
rows={2}
maxLength={5000}
required
placeholder="Descrever o que aconteceu..."
className="min-h-11 rounded-none border-0 bg-transparent px-0 py-1 shadow-none dark:bg-transparent"
/>
{bodyError ? <FieldError>{bodyError}</FieldError> : null}
</div>
<div className="px-4 py-3">
<FieldLabel id={polarityLabelId} className="sr-only">
Polaridade
</FieldLabel>
<input type="hidden" name="polarity" value={polarity} />
{polarityControl}
{polarityError ? <FieldError>{polarityError}</FieldError> : null}
</div>
<CheckRow id={urgentId} name="urgent" label="Urgente" isList />
{isStaff ? (
<CheckRow id={adversaryId} name="adversarySignal" label="Sinalizar adversário" isList />
) : null}
</div>
)
}
return (
<div className="flex flex-col gap-3">
@ -43,51 +166,24 @@ export const MunicipalityUpdateFields = ({
className="min-h-11"
placeholder="Ex.: Reunião com líderes, nova adesão, perda de apoio..."
/>
{fieldError(fieldErrors, 'body') ? (
<FieldError>{fieldError(fieldErrors, 'body')}</FieldError>
) : null}
{bodyError ? <FieldError>{bodyError}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor={polarityId}>Polaridade</FieldLabel>
<NativeSelect
id={polarityId}
name="polarity"
value={polarity}
onChange={(event) => setPolarity(event.target.value as MunicipalityUpdatePolarity)}
required
className="min-h-11 w-full"
>
{(['boa', 'neutra', 'ruim'] as const).map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalityUpdatePolarityLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldLabel id={polarityLabelId}>Polaridade</FieldLabel>
<input type="hidden" name="polarity" value={polarity} />
{polarityControl}
<FieldDescription>Selecione a polaridade do fato observado.</FieldDescription>
{fieldError(fieldErrors, 'polarity') ? (
<FieldError>{fieldError(fieldErrors, 'polarity')}</FieldError>
) : null}
{polarityError ? <FieldError>{polarityError}</FieldError> : null}
</Field>
<div className="flex items-center gap-3">
<input type="hidden" name="urgent" value="false" />
<Checkbox id={urgentId} name="urgent" value="true" className="mt-0.5" />
<FieldLabel htmlFor={urgentId} className="font-normal">
Urgente
</FieldLabel>
</div>
<CheckRow id={urgentId} name="urgent" label="Urgente" isList={false} />
{isStaff ? (
<div className="flex items-start gap-3">
<input type="hidden" name="adversarySignal" value="false" />
<Checkbox id={adversaryId} name="adversarySignal" value="true" className="mt-0.5" />
<div className="flex flex-col gap-1">
<FieldLabel htmlFor={adversaryId} className="font-normal">
Sinalizar adversário
</FieldLabel>
<FieldDescription>
Marque se este é um fato relacionado a um adversário.
</FieldDescription>
</div>
</div>
<CheckRow
id={adversaryId}
name="adversarySignal"
label="Sinalizar adversário"
description="Marque se este é um fato relacionado a um adversário."
isList={false}
/>
) : null}
</div>
)

View file

@ -10,6 +10,7 @@ import {
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox'
import { cn } from '@/lib/utils'
import { matchesAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { StrictComboboxOption } from '@/utilities/territory/territoryComboboxOptions'
@ -20,6 +21,8 @@ type StrictComboboxProps = {
onValueChange: (value: string) => void
onInvalid?: () => void
error?: string
placeholder?: string
className?: string
}
export const StrictCombobox = ({
@ -29,6 +32,8 @@ export const StrictCombobox = ({
onValueChange,
onInvalid,
error,
placeholder,
className,
}: StrictComboboxProps) => {
const selected = options.find((option) => option.value === value) ?? null
const [inputValue, setInputValue] = useState(selected?.label ?? '')
@ -55,7 +60,8 @@ export const StrictCombobox = ({
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-11 rounded-[6px]"
placeholder={placeholder}
className={cn('min-h-11 rounded-[6px]', className)}
showClear={Boolean(value)}
aria-invalid={Boolean(error)}
aria-describedby={error ? `${id}-error` : undefined}

View file

@ -595,7 +595,7 @@ test.describe('Municípios — jornadas por papel', () => {
await signalForm
.getByRole('textbox', { name: 'Texto' })
.fill('Liderança local reportou visita adversária na feira.')
await signalForm.getByLabel('Polaridade').selectOption('ruim')
await signalForm.getByLabel('Polaridade').getByRole('radio', { name: 'Ruim' }).click()
await signalForm.getByLabel('Sinalizar adversário').check()
await Promise.all([
expectPostResponse(page, '/campanha/municipios'),

View file

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement, type ReactElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
@ -318,9 +318,11 @@ describe('campaign cell edit overlay', () => {
)
const dialog = await screen.findByRole('dialog')
// The select is `required`: an invalid form never fires submit, and the
// case would pass for the wrong reason.
fireEvent.change(screen.getByLabelText('Polaridade'), { target: { value: 'boa' } })
// The polarity toggle (C107) is `single` with `neutra` as default; the
// sheet renders the list layout, so the label is visually hidden but the
// group keeps its accessible name. Switching the value keeps the form
// valid and exercises the control before submit.
fireEvent.click(within(screen.getByLabelText('Polaridade')).getByRole('radio', { name: 'Boa' }))
fireEvent.change(screen.getByLabelText('Texto'), {
target: { value: 'Atualização registrada no campo.' },
})