diff --git a/docs/plans/c101-ux-pos-critique-impl.md b/docs/plans/c101-ux-pos-critique-impl.md
new file mode 100644
index 00000000..8364f2ce
--- /dev/null
+++ b/docs/plans/c101-ux-pos-critique-impl.md
@@ -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
(região de teclado, reachable mesmo com zero eventos)"]
+ A --> C[onKeyDown → useAgendaKeyboardNavigation]
+ C --> D[agendaKeyboardDirection pura
ArrowRight→next · ArrowLeft→prev · skips]
+ D --> E[preventDefault + api.next/prev]
+ E --> F["foco volta à região (grid re-renderiza
e destrói o nó focado)"]
+ G[onTitleClick.icon opcional no contrato] --> H[glyph aria-hidden no fim do título
mobile — affordance do tap]
+ H --> I["AgendaPeriodChrome passa RotateCcw
('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)
diff --git a/src/components/campaign/activity/ActivityAgenda.css b/src/components/campaign/activity/ActivityAgenda.css
index e7acaf29..6a424a18 100644
--- a/src/components/campaign/activity/ActivityAgenda.css
+++ b/src/components/campaign/activity/ActivityAgenda.css
@@ -190,6 +190,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;
}
diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx
index da15d350..0753d09d 100644
--- a/src/components/campaign/activity/ActivityAgenda.tsx
+++ b/src/components/campaign/activity/ActivityAgenda.tsx
@@ -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'
@@ -317,18 +318,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(
@@ -444,7 +455,21 @@ export const ActivityAgenda = ({
-
0} ref={containerRef}>
+
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}
+ >
{
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],
)
diff --git a/src/components/campaign/activity/useAgendaKeyboardNavigation.ts b/src/components/campaign/activity/useAgendaKeyboardNavigation.ts
new file mode 100644
index 00000000..b2e9b527
--- /dev/null
+++ b/src/components/campaign/activity/useAgendaKeyboardNavigation.ts
@@ -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
+ /** 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) => {
+ if (!enabledRef.current) return
+ const direction = agendaKeyboardDirection(event)
+ if (!direction) return
+ event.preventDefault()
+ onNavigateRef.current(direction)
+ containerRef.current?.focus({ preventScroll: true })
+ },
+ [containerRef],
+ )
+
+ return { handleKeyDown }
+}
diff --git a/src/components/campaign/shell/CampaignPageChromeText.tsx b/src/components/campaign/shell/CampaignPageChromeText.tsx
index c6e94c60..7361866d 100644
--- a/src/components/campaign/shell/CampaignPageChromeText.tsx
+++ b/src/components/campaign/shell/CampaignPageChromeText.tsx
@@ -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}
+ {chrome.title}
+ {chrome.onTitleClick.icon ? (
+
+ ) : null}
) : (
void; hint: string }
+ onTitleClick?: { action: () => void; hint: string; icon?: LucideIcon }
}
type CampaignRole = CampaignUser['role']
diff --git a/tests/e2e/campaignAgendaMobile.e2e.spec.ts b/tests/e2e/campaignAgendaMobile.e2e.spec.ts
index cc4a02b0..7062b141 100644
--- a/tests/e2e/campaignAgendaMobile.e2e.spec.ts
+++ b/tests/e2e/campaignAgendaMobile.e2e.spec.ts
@@ -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)
+ })
})
diff --git a/tests/unit/campaignPageChromeText.unit.spec.tsx b/tests/unit/campaignPageChromeText.unit.spec.tsx
new file mode 100644
index 00000000..0dfdffd2
--- /dev/null
+++ b/tests/unit/campaignPageChromeText.unit.spec.tsx
@@ -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(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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()
+ 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(
+ ,
+ )
+ expect(screen.queryByRole('button')).toBeNull()
+ expect(screen.getByText('9 Agosto')).toBeTruthy()
+ })
+})
diff --git a/tests/unit/useAgendaKeyboardNavigation.unit.spec.tsx b/tests/unit/useAgendaKeyboardNavigation.unit.spec.tsx
new file mode 100644
index 00000000..6f9aa1d3
--- /dev/null
+++ b/tests/unit/useAgendaKeyboardNavigation.unit.spec.tsx
@@ -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(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)
+ })
+})