mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #552 from fsolla/C107-atualizacoes-mobile-sheet-de-criacao-usavel-altura-auto
C107 — 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)
This commit is contained in:
commit
d60fe3867a
8 changed files with 397 additions and 76 deletions
100
docs/plans/atualizacoes-mobile-sheet-criacao-usavel-impl.md
Normal file
100
docs/plans/atualizacoes-mobile-sheet-criacao-usavel-impl.md
Normal 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
|
||||
72
docs/plans/quick-edit-sheet-mobile-quebrado.md
Normal file
72
docs/plans/quick-edit-sheet-mobile-quebrado.md
Normal 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).
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue