mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
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:
parent
a4d7b88375
commit
f3c41d3382
10 changed files with 564 additions and 14 deletions
81
docs/plans/c101-ux-pos-critique-impl.md
Normal file
81
docs/plans/c101-ux-pos-critique-impl.md
Normal 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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
65
tests/unit/campaignPageChromeText.unit.spec.tsx
Normal file
65
tests/unit/campaignPageChromeText.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
182
tests/unit/useAgendaKeyboardNavigation.unit.spec.tsx
Normal file
182
tests/unit/useAgendaKeyboardNavigation.unit.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue