feat(C101-ux): agenda mobile — navegação por teclado (região focável) + affordance do tap no título

- useAgendaKeyboardNavigation: ArrowLeft/Right → prev/next no mobile
  (FullCalendar v7 não tem setas no grid — verificado no bundle); skips
  para modificadores/dialog/menu/inputs; restore de foco na região
- Região de teclado mobile-only: tabIndex/role/aria-label gateados por
  isMobile (desktop mantém a toolbar e sua árvore a11y intacta)
- onTitleClick ganha icon?: LucideIcon (glyph da página, aria-hidden);
  agenda passa RotateCcw como affordance do 'voltar para hoje'
- E2E: teste de teclado (dia e mês), glyph no título, hardening do swipe
  CDP (pacing 25ms — burst era classificado como flick pelo Chromium e
  disparava back-nav ~35% das runs)
- Unit: 14 testes do hook/pura + 4 do renderer do glyph; impl plan
  docs/plans/c101-ux-pos-critique-impl.md (aprovado pelo humano)

Closes #512
This commit is contained in:
Francisco Solla 2026-08-10 00:53:19 -03:00
parent a4d7b88375
commit f3c41d3382
10 changed files with 564 additions and 14 deletions

View file

@ -0,0 +1,81 @@
# Impl: Agenda mobile — affordance do "voltar ao hoje" + navegação por teclado (pós-C101)
Status: aprovado
Atualizado em: 2026-08-09
Issue: #512
Intenção: docs/plans/c101-ux-pos-critique.md
Appetite: ~0,5 dia eng (fill-in pós-entrega)
## Leitura da intenção
- **Outcome:** no mobile da agenda, (1) usuários de teclado/AT conseguem navegar entre períodos mesmo com a toolbar do FullCalendar escondida (C101), e (2) o tap no título do header ("voltar ao hoje") tem uma pista visual — hoje só existe `title` (hover), invisível no toque.
- **O que NÃO negociar:** desktop intacto (toolbar continua mandando lá); swipe continua sendo o gesto primário; C91 (tap cria inline) e C15 (arrasto de remanejo) intactos; nada de segunda barra de controles.
- **O que reavaliar:** a hipótese de "reusar o teclado interno do FC" — **verificado no bundle instalado (FullCalendar 7.0.2): o FC v7 NÃO tem navegação por setas no grid.** Varredura dos chunks: só existe `createAriaKeyboardAttrs` (Enter/Space ativa elementos clicáveis — células com `dateClick` e âncoras de evento) e Escape no more-popover. Não há handler ArrowLeft/ArrowRight de navegação de data em nenhum lugar do core/headless-calendar/react. → Handler próprio é obrigatório; a hipótese cai. Bônus da verificação: células de data com `dateClick` JÁ são focáveis (`tabIndex=0`), então teclado já alcança o calendário via células — mas sem navegação.
## Abordagem recomendada
```mermaid
flowchart LR
A[.activity-agenda div] --> B["mobile: tabIndex=0 + role=group + aria-label<br/>(região de teclado, reachable mesmo com zero eventos)"]
A --> C[onKeyDown → useAgendaKeyboardNavigation]
C --> D[agendaKeyboardDirection pura<br/>ArrowRight→next · ArrowLeft→prev · skips]
D --> E[preventDefault + api.next/prev]
E --> F["foco volta à região (grid re-renderiza<br/>e destrói o nó focado)"]
G[onTitleClick.icon opcional no contrato] --> H[glyph aria-hidden no fim do título<br/>mobile — affordance do tap]
H --> I["AgendaPeriodChrome passa RotateCcw<br/>('voltar') · tooltip 'Voltar para hoje' segue"]
```
**Opções consideradas (teclado):** A | B | C | D
**Recomendação:** A — **região de teclado**: o container `.activity-agenda` vira, no mobile, um elemento focável (`tabIndex={0}`) com `role="group"` + `aria-label`; um `onKeyDown` no container traduz ArrowLeft/ArrowRight em `api.prev()/api.next()` (mesma semântica do swipe: direita = avança). Focar a região resolve também o "calendário inalcançável por teclado quando o período não tem eventos e não há células focáveis" (hoje, com 0 eventos, não há nada tabbable no grid mobile — só o more-popover e as âncoras de evento). É o padrão APG de região de teclado, sem lutar contra o grid.
**Rejeitadas:** B — handler em nível de `document` gated por "foco em qualquer lugar da página da agenda": engole setas perto do combobox/seletor de vista, exige as mesmas regras de skip e ainda dispara quando o usuário só passou de tab; C — emular roving tabindex sobre as células de data: o FC v7 não expõe API de foco de célula (não há navegação interna para espelhar), seria lutar contra o grid por dentro; D — nada: falha o aceite (teclado no mobile não sai do período, e com zero eventos o calendário é inalcançável).
**Opções consideradas (affordance):** A | B | C
**Recomendação:** A — **glyph discreto dentro do próprio botão de título** (móvel): o contrato `onTitleClick` ganha `icon?: LucideIcon` opcional (glyph é da página, mesmo padrão do `hint` — o chrome compartilhado não hardcoda copy da agenda), renderizado `aria-hidden` após o texto truncado. Agenda passa `RotateCcw` ("voltar"). Zero controles novos, o tap continua sendo UM alvo.
**Rejeitadas:** B — chip "Hoje" separado ao lado do título: segundo alvo de toque, disputa espaço com `[Semana ▾][sino]`, e torna o tap no título redundante (vizinho do anti-goal "segunda barra"); C — nada além do affordance de botão: é exatamente o gap que o critique apontou (o `title` hover-only é invisível no toque). Nota de gate: a escolha do glyph é troca de uma linha no Impeccable B/C.
### Componentes / mudanças
- **`useAgendaKeyboardNavigation`** (`src/components/campaign/activity/useAgendaKeyboardNavigation.ts`): hook novo, agenda-only, espelhando a forma do `useAgendaSwipeNavigation` (refs de `enabled`/`onNavigate`, sem stale closure). Exports a pura `agendaKeyboardDirection(event)`:
- `ArrowRight → 'next'`, `ArrowLeft → 'prev'`; qualquer outra tecla/modificador → `null`.
- Skips: `alt/ctrl/meta` pressionados (não sequestrar atalhos do browser/AT); `target.closest('[role="dialog"], [role="menu"]')` (more-popover do FC é dialog; menus têm setas próprias); `target` é input/textarea/select/contentEditable (defensivo — não há inputs no grid).
- `event.repeat` permitido (paridade com segurar Enter na toolbar desktop; o fetch de eventos já cancela requisições atrasadas via flag `cancelled`).
- No consumo: `preventDefault()` + `onNavigate(direction)` + **restaura foco na região** (`containerRef.current.focus({ preventScroll: true })`) — trocar de período re-renderiza o grid e destrói o nó focado (âncora de evento/célula); a região sobrevive à troca e mantém o teclado navegando.
- **`ActivityAgenda.tsx`**: no div `.activity-agenda` (mobile-only, `isMobile` — mesmo gate do chrome): `tabIndex={isMobile ? 0 : undefined}`, `role="group"`, `aria-label="Calendário de atividades — setas mudam o período"` e `onKeyDown={handleKeyDown}` do hook. Desktop intacto (sem tabIndex, sem handler).
- **`campaignPageChrome.ts`**: `onTitleClick` ganha `icon?: LucideIcon` (opcional; página decide o glyph).
- **`CampaignPageChromeText.tsx`** (branch mobile): botão vira `flex items-center gap-1.5`; texto num `span` com `min-w-0 truncate`; quando `onTitleClick.icon`, glyph `size-3.5 shrink-0` `aria-hidden` no fim. Accessible name inalterado (o texto do período continua sendo o nome). Branch desktop intocado.
- **`AgendaPeriodChrome.tsx`**: passa `icon: RotateCcw` no chrome memo.
- **`ActivityAgenda.css`**: no media ≤767px, `.activity-agenda:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px }` (anel interno sutil na região focada — a região é navegável, precisa de foco visível; o anel interno não desloca layout). Sem nada além disso.
- **Migration:** nenhuma (só UI/interação de tela). Nenhum `Consent`/access novo.
### Dados → forma
- Não apresenta dados novos: navegação por teclado e affordance são estado de tela/copy. (N/A.)
## Fases verificáveis
1. **Teclado (a11y primeiro):** pura `agendaKeyboardDirection` + hook `useAgendaKeyboardNavigation` + unit tests (mapeamento, skips de modificador/dialog/menu/input, preventDefault, restore de foco) + wiring no `ActivityAgenda` + CSS do anel.
2. **Affordance:** `icon` no contrato + glyph no `CampaignPageChromeText` + passagem pelo `AgendaPeriodChrome` + unit test do renderer (glyph aparece só com `icon`; desktop inalterado). Gate Impeccable B/C para a escolha do glyph (swap de 1 linha se o review pedir).
3. **Gates:** e2e novos no `campaignAgendaMobile.e2e.spec.ts` (teclado: focar região → ArrowRight → período seguinte, ArrowLeft → volta; glyph visível no botão do título no mobile; desktop com "Hoje" da toolbar intacto — verificar asserção existente no `campaignActivity`); `pnpm gate:fast` na iteração; `pnpm push` na entrega.
## Rabbit holes / Não escopo (engenharia)
- ArrowUp/ArrowDown e Home/End: NÃO tocar — são a rolagem nativa da linha do tempo do dia (e da página), comportamento desejado.
- Roving tabindex dentro do grid (impossível sem API do FC — ver rejeitadas) e navegação por teclado no desktop (a toolbar é o controle, já acessível).
- Throttle de `event.repeat`, anúncio SR do período trocado (aria-live no title já cobre via texto), teclado dentro do more-popover/do combobox do strip (fora da região por construção; skips cobrem o caso de foco interno).
- Qualquer mudança no gesto de swipe, no strip, no desktop, e nas outras listas.
## Riscos e mitigação
- **Foco perdido na troca de período** (grid re-renderiza): restore para a região com `preventScroll: true` no próprio consumo — deterministicamente o teclado continua no calendário.
- **Conflito futuro com teclado interno do FC** (se um v7.x adicionar setas no grid): hoje não existe (verificado); se vier, o handler do container cai antes (o keydown das células borbulha até ele) — registrar como gatilho de revisão, não reabrir agora.
- **Anel de foco feio no card edge-to-edge**: anel interno de 2px com `outline-offset: -2px` (sem deslocamento); gate visual no browser.
- **E2E de teclado no viewport mobile**: esperar o label do período (medição `isMobile` assentou — padrão B167 existente) antes de focar a região; `page.keyboard.press` não precisa de CDP.
- **Glyph no título móvel disputa espaço com "3–9 Agosto" truncado**: `shrink-0` + span truncante; conferir no browser (o título já truncava).
## Aceite de engenharia
- [ ] Teclado navega períodos no mobile (ArrowRight/ArrowLeft por vista) sem conflitar com nada do FC (v7 não tem setas no grid — verificado) nem com popover/menu/inputs
- [ ] Tap no título comunica a ação sem depender de hover (glyph visível, aria-hidden; accessible name preservado)
- [ ] Desktop, swipe, C91 e C15 intactos; gates verdes (gate:fast + push)
- [ ] Invariantes AGENTS/engineering-standards (sem migration, sem prod, sem Consent novo, copy pt-BR / ids em inglês)
- [ ] Testes: unit (direção/skips/focus; renderer do glyph) + e2e (teclado, glyph, desktop intacto)

View file

@ -189,6 +189,16 @@
font-size: 0.75rem;
}
/* C101-ux — the calendar container is a keyboard region on phones (it takes
tabIndex because the toolbar is hidden): a visible focus indication is
required. The ring stays INSIDE the card (negative offset) because the
edge-to-edge shell clips with `overflow: hidden` — a positive-offset
ring would be cut at the screen edge. */
.activity-agenda:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.activity-agenda h2 {
font-size: 0.9375rem;
}

View file

@ -30,6 +30,7 @@ import {
type ActivityInlineCreateDraft,
} from '@/components/campaign/activity/ActivityInlineCreate'
import { AgendaPeriodChrome } from '@/components/campaign/activity/AgendaPeriodChrome'
import { useAgendaKeyboardNavigation } from '@/components/campaign/activity/useAgendaKeyboardNavigation'
import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgendaSwipeNavigation'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
@ -297,18 +298,28 @@ export const ActivityAgenda = ({
draggingEventRef.current = false
}, [])
const handleSwipeNavigation = useCallback((direction: 'next' | 'prev') => {
// C101-ux — the calendar container is a keyboard region on phones: the
// FullCalendar toolbar (its native keyboard controls) is hidden there, so
// ArrowLeft/ArrowRight navigate periods with the same semantics as the
// swipe gesture (right moves forward).
const handlePeriodNavigation = useCallback((direction: 'next' | 'prev') => {
const api = calendarRef.current?.getApi()
if (!api) return
if (direction === 'next') api.next()
else api.prev()
}, [])
const { handleKeyDown } = useAgendaKeyboardNavigation({
containerRef,
enabled: isMobile,
onNavigate: handlePeriodNavigation,
})
const { suppressDateClickRef } = useAgendaSwipeNavigation({
containerRef,
enabled: isMobile,
blockRef: draggingEventRef,
onSwipe: handleSwipeNavigation,
onSwipe: handlePeriodNavigation,
})
const handleDateClick = useCallback(
@ -424,7 +435,21 @@ export const ActivityAgenda = ({
<AgendaPeriodChrome label={isMobile ? periodLabel : null} onToday={handleToday} />
<div className="activity-agenda" aria-busy={isLoading || savingCount > 0} ref={containerRef}>
<div
className="activity-agenda"
aria-busy={isLoading || savingCount > 0}
// C101-ux — mobile-only keyboard region: the toolbar (and its native
// keyboard controls) is hidden on phones, so the container itself
// takes tabIndex and the arrow keys navigate periods. Desktop keeps
// the toolbar as its keyboard interface — the role/label are gated
// too, so the desktop a11y tree does not claim a behavior the
// desktop lacks.
tabIndex={isMobile ? 0 : undefined}
role={isMobile ? 'group' : undefined}
aria-label={isMobile ? 'Calendário de atividades — setas mudam o período' : undefined}
onKeyDown={handleKeyDown}
ref={containerRef}
>
<FullCalendar
ref={calendarRef}
plugins={[themePlugin, timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin]}

View file

@ -1,5 +1,6 @@
'use client'
import { RotateCcw } from 'lucide-react'
import { useMemo } from 'react'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
@ -11,6 +12,8 @@ import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageC
* tapping it returns to today (the "Hoje" toolbar button is hidden on
* phones). Desktop never mounts this — the agenda only renders it when the
* mobile top bar is the visible chrome.
* C101-ux — the tap affordance: the RotateCcw glyph (aria-hidden in the
* shared chrome) signals the title is tappable without a hover-only tooltip.
*/
export const AgendaPeriodChrome = ({
label,
@ -21,7 +24,12 @@ export const AgendaPeriodChrome = ({
}) => {
const chrome = useMemo(
() =>
label ? { title: label, onTitleClick: { action: onToday, hint: 'Voltar para hoje' } } : null,
label
? {
title: label,
onTitleClick: { action: onToday, hint: 'Voltar para hoje', icon: RotateCcw },
}
: null,
[label, onToday],
)

View file

@ -0,0 +1,91 @@
'use client'
import { useCallback, useRef, type KeyboardEvent, type RefObject } from 'react'
export type AgendaKeyboardDirection = 'next' | 'prev'
const AGENDA_KEYBOARD_SKIP_SELECTOR = '[role="dialog"], [role="menu"]'
/**
* C101-ux — pure decision for the agenda's keyboard period navigation. FullCalendar
* v7 has no arrow-key navigation of its own (verified in the installed bundle:
* only Enter/Space activation attrs and Escape in the more-popover), so the
* mobile agenda maps ArrowLeft/ArrowRight to prev/next on the keyboard-region
* container — the same direction semantics as the swipe gesture (right moves
* forward).
*
* Returns `null` (and the caller does nothing) when the key is not a period
* arrow, when a modifier is held (never hijack browser/AT shortcuts), or when
* the event's target lives inside a construct with its own arrow semantics:
* the FullCalendar more-popover (`role="dialog"`) and menus. Form controls are
* skipped defensively — the grid has none, but a future FC internals change
* must not break typing.
*/
export const agendaKeyboardDirection = (event: {
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
target: EventTarget | null
}): AgendaKeyboardDirection | null => {
if (event.altKey || event.ctrlKey || event.metaKey) return null
const target = event.target
if (target instanceof Element) {
if (target.closest(AGENDA_KEYBOARD_SKIP_SELECTOR)) return null
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return null
}
}
if (event.key === 'ArrowRight') return 'next'
if (event.key === 'ArrowLeft') return 'prev'
return null
}
/**
* C101-ux — keyboard period navigation for the mobile agenda. The calendar
* container is a keyboard region on phones (mobile-only `tabIndex={0}`,
* `role="group"`), so ArrowLeft/ArrowRight reach it and map to prev/next —
* mirroring `useAgendaSwipeNavigation`'s shape: render-gated via `enabled`
* (the same `isMobile` signal as the C101 chrome), and ref-based so the
* memoized handler always sees the current `enabled`/`onNavigate`.
*
* After navigating, focus is restored to the region: changing the period
* re-renders the grid and destroys the focused node (event anchor / date
* cell); the region survives the transition and keeps the keyboard navigating.
* `preventScroll` keeps the restore from scrolling the timeline.
*/
export const useAgendaKeyboardNavigation = ({
containerRef,
enabled,
onNavigate,
}: {
containerRef: RefObject<HTMLElement | null>
/** Render-time gate (viewport-driven): when false, no key navigates. */
enabled: boolean
onNavigate: (direction: AgendaKeyboardDirection) => void
}) => {
const enabledRef = useRef(enabled)
const onNavigateRef = useRef(onNavigate)
enabledRef.current = enabled
onNavigateRef.current = onNavigate
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (!enabledRef.current) return
const direction = agendaKeyboardDirection(event)
if (!direction) return
event.preventDefault()
onNavigateRef.current(direction)
containerRef.current?.focus({ preventScroll: true })
},
[containerRef],
)
return { handleKeyDown }
}

View file

@ -32,9 +32,15 @@ export const CampaignPageChromeText = ({
data-slot="campaign-page-chrome-title"
title={chrome.onTitleClick.hint}
onClick={chrome.onTitleClick.action}
className="block max-w-full truncate text-left text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex min-w-0 max-w-full items-center gap-1.5 text-left text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{chrome.title}
<span className="block min-w-0 truncate">{chrome.title}</span>
{chrome.onTitleClick.icon ? (
<chrome.onTitleClick.icon
className="size-3.5 shrink-0 text-current"
aria-hidden="true"
/>
) : null}
</button>
) : (
<span

View file

@ -1,3 +1,4 @@
import type { LucideIcon } from 'lucide-react'
import type { Metadata } from 'next'
import { CAMPAIGN_HOME, CAMPAIGN_UPDATES_HREF, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
@ -14,8 +15,11 @@ export type CampaignPageChrome = {
* phones. Only client-rendered overrides set it — the catalog never does.
* The `hint` is the page's own copy (rendered as the button tooltip), so
* the shared chrome does not hardcode agenda wording.
* C101-ux — the optional `icon` is the page's own affordance glyph for the
* tap (rendered trailing the title, aria-hidden), for the same reason the
* copy is page-owned: the shared chrome stays generic.
*/
onTitleClick?: { action: () => void; hint: string }
onTitleClick?: { action: () => void; hint: string; icon?: LucideIcon }
}
type CampaignRole = CampaignUser['role']

View file

@ -18,12 +18,31 @@ import {
const mobileHeaderTitle = (page: Page): Locator =>
page.locator('[data-slot="campaign-mobile-top-bar"] [data-slot="campaign-page-chrome-title"]')
/**
* C95 — troca a vista para Mês pelo seletor do header (menu no popover).
* Compartilhado pelos testes que navegam o mês com swipe ou teclado.
*/
const openMonthView = async (page: Page) => {
const viewSelector = page
.getByRole('button', { name: /Modo de visualização/ })
.filter({ visible: true })
await viewSelector.click()
await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click()
}
/**
* Horizontal swipe via the CDP touch pipeline (the same input path a real
* device takes — the only way to exercise touch gestures in Playwright).
* The swipe stays at the vertical middle of the calendar, clear of fixture
* events (which sit at 10:00), and completes far below FullCalendar's 650ms
* long-press, so it is a navigation, not a reschedule.
* events (which sit at 10:00).
*
* The 5 moves are paced (~25ms apart, ~125ms total): a burst without gaps is
* classified by Chromium's gesture recognizer as a FLICK, and a dominant
* horizontal flick is a browser navigation gesture (back/forward history),
* not a page drag — intermittently it took the "back to today" swipe to
* `/campanha`. Paced, it stays a drag the agenda consumes via its
* non-passive touchmove preventDefault, while still finishing far below
* FullCalendar's 650ms long-press (which would start a reschedule).
*/
const touchSwipe = async (
cdp: CDPSession,
@ -38,6 +57,7 @@ const touchSwipe = async (
for (let i = 1; i <= 5; i += 1) {
const x = Math.round(x0 + ((x1 - x0) * i) / 5)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] })
await new Promise((resolve) => setTimeout(resolve, 25))
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
@ -91,6 +111,10 @@ test.describe('C101 — agenda mobile com cara de app nativo', () => {
await expect(page.getByRole('button', { name: 'Dia Anterior' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Próximo Dia' })).toHaveCount(0)
// C101-ux — o tap no título tem affordance visual: o glyph (aria-hidden)
// sinaliza o controle sem depender do tooltip hover-only.
await expect(mobileHeaderTitle(page).locator('svg')).toBeVisible()
// Navegação por arrasto: esquerda → dia seguinte; direita → volta. O
// swipe direito começa longe da borda esquerda (x=0.4): um gesto que
// nasça na borda é do Chrome (histórico), não da agenda.
@ -124,11 +148,7 @@ test.describe('C101 — agenda mobile com cara de app nativo', () => {
// Contexto por vista: mês → nome do mês; swipe no mês → mês seguinte;
// lista → "Agenda".
const viewSelector = page
.getByRole('button', { name: /Modo de visualização/ })
.filter({ visible: true })
await viewSelector.click()
await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click()
await openMonthView(page)
const monthIndex = Number(civilDate.slice(5, 7)) - 1
await expect(campaignPageChrome(page, ptBrMonthNames[monthIndex] ?? '')).toBeVisible({
timeout: 15_000,
@ -137,6 +157,9 @@ test.describe('C101 — agenda mobile com cara de app nativo', () => {
await expect(campaignPageChrome(page, ptBrMonthNames[(monthIndex + 1) % 12] ?? '')).toBeVisible(
{ timeout: 15_000 },
)
const viewSelector = page
.getByRole('button', { name: /Modo de visualização/ })
.filter({ visible: true })
await viewSelector.click()
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible({ timeout: 15_000 })
@ -268,4 +291,59 @@ test.describe('C101 — agenda mobile com cara de app nativo', () => {
expect(geometry!.stripWidth).toBe(geometry!.windowWidth)
expect(geometry!.sticky).toBe('sticky')
})
test('teclado navega períodos com ArrowLeft/ArrowRight (região focável no mobile)', async ({
campaign,
page,
}) => {
const civilDate = await seedTodayActivity(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
// Espera a medição mobile assentar (o label do período só existe com o
// chrome mobile) antes de focar a região de teclado.
const todayLabel = dayLabelFor(civilDate)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
// C101-ux — o container do calendário é a região de teclado do mobile
// (a toolbar — e seus controles de teclado — sumiu): focável, com label
// próprio, mesmo quando o período não tem eventos focáveis.
const region = page.locator('.activity-agenda').first()
await expect(region).toHaveAttribute('role', 'group')
await expect(region).toHaveAttribute('aria-label', /setas mudam o período/)
await region.focus()
await expect(region).toBeFocused()
// ArrowRight → período seguinte; ArrowLeft → período anterior (mesma
// semântica do swipe: direita avança).
const nextCivil = civilDatePlusDays(civilDate, 1)
await page.keyboard.press('ArrowRight')
await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({
timeout: 15_000,
})
await page.keyboard.press('ArrowLeft')
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
// O foco permanece na região após navegar (a restauração do foco quando
// um nó do grid é destruído é pinada no unit do hook; aqui o contrato é
// que o teclado continua na região depois da troca).
await expect(region).toBeFocused()
// Mês: setas navegam meses conforme a vista (mesmo contrato de foco).
await openMonthView(page)
const monthIndex = Number(civilDate.slice(5, 7)) - 1
await expect(campaignPageChrome(page, ptBrMonthNames[monthIndex] ?? '')).toBeVisible({
timeout: 15_000,
})
await region.focus()
await page.keyboard.press('ArrowRight')
await expect(campaignPageChrome(page, ptBrMonthNames[(monthIndex + 1) % 12] ?? '')).toBeVisible(
{ timeout: 15_000 },
)
await expect(region).toBeFocused()
// Setas não criam inline create (o dateClick não dispara por teclado).
await expect(page.getByRole('dialog')).toHaveCount(0)
})
})

View file

@ -0,0 +1,65 @@
import { cleanup, render, screen } from '@testing-library/react'
import { CalendarDays } from 'lucide-react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CampaignPageChromeText } from '@/components/campaign/shell/CampaignPageChromeText'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('CampaignPageChromeText (mobile)', () => {
it('renderiza o título como botão quando há onTitleClick', () => {
const action = vi.fn()
render(
<CampaignPageChromeText
layout="mobile"
chrome={{ title: '9 Agosto', onTitleClick: { action, hint: 'Voltar para hoje' } }}
/>,
)
const button = screen.getByRole('button', { name: '9 Agosto' })
button.click()
expect(action).toHaveBeenCalled()
// Sem `icon` no contrato, o botão não renderiza glyph nenhum.
expect(button.querySelector('svg')).toBeNull()
})
it('renderiza o glyph do affordance (aria-hidden) quando onTitleClick.icon existe', () => {
render(
<CampaignPageChromeText
layout="mobile"
chrome={{
title: '9 Agosto',
onTitleClick: { action: vi.fn(), hint: 'Voltar para hoje', icon: CalendarDays },
}}
/>,
)
const button = screen.getByRole('button', { name: '9 Agosto' })
const icon = button.querySelector('svg')
expect(icon).not.toBeNull()
expect(icon?.getAttribute('aria-hidden')).toBe('true')
})
it('não renderiza glyph sem icon no contrato', () => {
render(<CampaignPageChromeText layout="mobile" chrome={{ title: 'Agenda' }} />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('Agenda')).toBeTruthy()
})
})
describe('CampaignPageChromeText (desktop)', () => {
it('não vira botão nem renderiza glyph — o chrome do desktop é texto', () => {
render(
<CampaignPageChromeText
layout="desktop"
chrome={{
title: '9 Agosto',
onTitleClick: { action: vi.fn(), hint: 'Voltar para hoje', icon: CalendarDays },
}}
/>,
)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('9 Agosto')).toBeTruthy()
})
})

View file

@ -0,0 +1,182 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createElement, useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
agendaKeyboardDirection,
useAgendaKeyboardNavigation,
} from '@/components/campaign/activity/useAgendaKeyboardNavigation'
const keyEvent = (
key: string,
init: {
target?: EventTarget | null
altKey?: boolean
ctrlKey?: boolean
metaKey?: boolean
} = {},
) => ({
key,
altKey: init.altKey ?? false,
ctrlKey: init.ctrlKey ?? false,
metaKey: init.metaKey ?? false,
target: init.target ?? null,
})
const Harness = ({
enabled = true,
onNavigate,
withChild = false,
}: {
enabled?: boolean
onNavigate: (direction: 'next' | 'prev') => void
/** Simulates the grid content inside the region (date cells / event anchors). */
withChild?: boolean
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const { handleKeyDown } = useAgendaKeyboardNavigation({
containerRef,
enabled,
onNavigate,
})
return createElement(
'div',
{
ref: containerRef,
tabIndex: 0,
onKeyDown: handleKeyDown,
'data-testid': 'agenda',
},
withChild ? createElement('button', { 'data-testid': 'cell' }, 'célula') : null,
)
}
const pressKey = (container: HTMLElement, key: string) => {
const agenda = container.firstElementChild as HTMLElement
fireEvent.keyDown(agenda, { key })
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('agendaKeyboardDirection', () => {
it('mapeia ArrowRight para o período seguinte', () => {
expect(agendaKeyboardDirection(keyEvent('ArrowRight'))).toBe('next')
})
it('mapeia ArrowLeft para o período anterior', () => {
expect(agendaKeyboardDirection(keyEvent('ArrowLeft'))).toBe('prev')
})
it('ignora teclas que não são setas de período', () => {
expect(agendaKeyboardDirection(keyEvent('ArrowUp'))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('ArrowDown'))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('Home'))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('Enter'))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('a'))).toBeNull()
})
it('não sequestra atalhos com modificador (Alt/Ctrl/Meta)', () => {
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { altKey: true }))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { ctrlKey: true }))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { metaKey: true }))).toBeNull()
})
it('ignora setas dentro do more-popover do FullCalendar (role=dialog)', () => {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const cell = document.createElement('button')
dialog.appendChild(cell)
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { target: cell }))).toBeNull()
})
it('ignora setas dentro de menus (o menu do seletor de vista tem setas próprias)', () => {
const menu = document.createElement('nav')
menu.setAttribute('role', 'menu')
const item = document.createElement('a')
menu.appendChild(item)
expect(agendaKeyboardDirection(keyEvent('ArrowLeft', { target: item }))).toBeNull()
})
it('ignora setas em controles de formulário (defensivo)', () => {
const input = document.createElement('input')
const textarea = document.createElement('textarea')
const select = document.createElement('select')
// jsdom não implementa isContentEditable (sempre false) — simula o
// elemento editável para pinar o ramo defensivo.
const contentEditable = document.createElement('div')
Object.defineProperty(contentEditable, 'isContentEditable', { value: true })
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { target: input }))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { target: textarea }))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { target: select }))).toBeNull()
expect(agendaKeyboardDirection(keyEvent('ArrowRight', { target: contentEditable }))).toBeNull()
})
it('aceita alvos fora do DOM (target null ou não-Element)', () => {
expect(agendaKeyboardDirection(keyEvent('ArrowRight'))).toBe('next')
const text = document.createTextNode('x')
expect(agendaKeyboardDirection(keyEvent('ArrowLeft', { target: text }))).toBe('prev')
})
})
describe('useAgendaKeyboardNavigation', () => {
it('navega para o período seguinte com ArrowRight', () => {
const onNavigate = vi.fn()
const { container } = render(createElement(Harness, { onNavigate }))
pressKey(container as HTMLElement, 'ArrowRight')
expect(onNavigate).toHaveBeenCalledWith('next')
})
it('navega para o período anterior com ArrowLeft', () => {
const onNavigate = vi.fn()
const { container } = render(createElement(Harness, { onNavigate }))
pressKey(container as HTMLElement, 'ArrowLeft')
expect(onNavigate).toHaveBeenCalledWith('prev')
})
it('não navega com enabled=false (desktop mantém a toolbar)', () => {
const onNavigate = vi.fn()
const { container } = render(createElement(Harness, { enabled: false, onNavigate }))
pressKey(container as HTMLElement, 'ArrowRight')
pressKey(container as HTMLElement, 'ArrowLeft')
expect(onNavigate).not.toHaveBeenCalled()
})
it('bloqueia o scroll nativo da seta quando navega', () => {
const onNavigate = vi.fn()
const { container } = render(createElement(Harness, { onNavigate }))
const agenda = container.firstElementChild as HTMLElement
// fireEvent retorna o valor de dispatchEvent: false quando preventDefault
// foi chamado (o default da seta — scroll — é cancelado).
const dispatched = fireEvent.keyDown(agenda, { key: 'ArrowRight' })
expect(dispatched).toBe(false)
expect(onNavigate).toHaveBeenCalled()
})
it('não impede o default de teclas que não navegam', () => {
const onNavigate = vi.fn()
const { container } = render(createElement(Harness, { onNavigate }))
const agenda = container.firstElementChild as HTMLElement
const dispatched = fireEvent.keyDown(agenda, { key: 'ArrowUp' })
expect(dispatched).toBe(true)
expect(onNavigate).not.toHaveBeenCalled()
})
it('restaura o foco na região quando a troca de período destrói o nó focado', () => {
const onNavigate = vi.fn()
const { container } = render(createElement(Harness, { onNavigate, withChild: true }))
const agenda = container.firstElementChild as HTMLElement
const cell = agenda.querySelector('[data-testid="cell"]') as HTMLElement
cell.focus()
expect(document.activeElement).toBe(cell)
// A troca de período re-renderiza o grid e destrói o nó focado (âncora
// de evento / célula): o handler já levou o foco para a região, então a
// destruição do filho não derruba o teclado no body.
fireEvent.keyDown(cell, { key: 'ArrowRight' })
expect(document.activeElement).toBe(agenda)
cell.remove()
expect(document.activeElement).toBe(agenda)
})
})