mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C110): feedback visual do arrasto na agenda mobile — reveal do período adjacente + commit/snap-back
This commit is contained in:
parent
89e7292529
commit
b23a323f83
9 changed files with 1376 additions and 114 deletions
79
docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md
Normal file
79
docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Impl: C110 — Agenda mobile: feedback visual do arrasto (reveal do período adjacente + commit/snap-back)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #589
|
||||
Intenção: docs/plans/c110-agenda-mobile-feedback-arrasto.md
|
||||
Appetite restante: ~1 dia eng (herdado; cabe nas fases abaixo sem corte)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** durante o arrasto horizontal no calendário mobile (dia/semana/mês/lista), o grid **acompanha o dedo em tempo real** e o período adjacente é **revelado progressivamente** na direção do gesto (quadro do grid + cabeçalho com datas + chevron), com os **eventos do adjacente entrando no preview assíncronos** (degradação aceita: quadro sem eles, entram quando chegam). Soltar abaixo de 48 px → **snap-back** sem navegação; acima → **commit** com transição suave. `prefers-reduced-motion` → troca instantânea (como hoje).
|
||||
- **O que NÃO negociar:** desktop intacto (chrome/toolbar atuais); tap de criação inline (C91) e long-press de remanejo (C15) intactos; vista de dia (coluna única) segue sem abrir criação após arrasto; contrato e2e CDP do gesto atualizado no mesmo item; sem segunda barra de controles; sem mudar o header (título continua atualizando só no commit).
|
||||
- **O que reavaliar:** a hipótese da intenção de "expor o delta vivo do gesto" (decisão passa a ser no soltar) — **confirmado como o caminho**, mas com uma restrição de engenharia que a intenção não nomeia: o delta NÃO pode virar estado React por frame (re-render do FullCalendar a 60 fps é caro). O delta vivo vira transform imperativo no container, no próprio hook dono do gesto; o React só entra nos eventos discretos (preview liga/desliga).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[useAgendaSwipeNavigation: claim 12px → touchcancel sintético + previewStart] --> B[transform imperativo no container]
|
||||
B --> C[preview: painel filho left:100%/right:100% + chevron + label + frame + barras]
|
||||
C --> D[onSwipePreviewEnd + onSwipe no soltar]
|
||||
D --> E[commit ≥48px: transição dx→0 + api.next/prev]
|
||||
D --> F[<48px: snap-back dx→0]
|
||||
G[activityAgendaAdjacentPeriod puro] --> H[loadActivityAgendaEvents assíncrono + cache por direção]
|
||||
H --> C
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C | D | E
|
||||
**Recomendação:** A — o hook continua dono do gesto (contrato unit-testado) e passa a aplicar o transform imperativo no container (zero re-render do FC por frame); o preview é um painel filho do container transformado em `left: 100%`/`right: 100%` (a costura do grid e do painel se move junto — a faixa revelada mostra a borda do painel sem coordenar dois elementos); a decisão (commit vs snap-back) acontece no `pointerup` com o limiar atual de 48 px.
|
||||
**Rejeitadas:** B — segundo FullCalendar vivo para folhear de verdade (rabbit hole da intenção: render extra + chamada de dados por período); C — `swipeDx` em estado React atualizado por `pointermove` (re-render do FC por frame — 60 fps de reconciliação cara); D — CSS var `--swipe-dx` compartilhada entre container e painel (exige ref da shell, indireção sem ganho: o painel filho do container já herda o transform); E — manter commit no meio do gesto + só animar (o snap-back abaixo do limiar não existe se a troca já aconteceu — aceite exige decisão no soltar).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`activityAgendaAdjacentPeriod`** (`src/utilities/activityUi.ts`): pura — `(view, { start, end, anchorDate }, direction) => { start, end, anchorDate } | null`. Espelha a aritmética do FC (dia ±1d; semana ±7d sobre os instantes — Bahia não tem DST, instante ± 86.400.000 ms é dia civil exato; mês/lista: anchor civil ±1 mês, range do mês = [segunda ≤ 1º, +42d] — cobre o grid de 5–6 semanas e fica ≤ cap de 45 dias do schema; label usa só o anchor). O label do preview sai do MESMO `activityAgendaPeriodLabel` sobre o range adjacente → garante label do preview == título pós-commit.
|
||||
- **`useAgendaSwipeNavigation`** (`src/components/campaign/activity/useAgendaSwipeNavigation.ts`): contrato reworkado:
|
||||
- `onSwipe(direction)` — agora dispara **só no commit** (`pointerup` com |dx| ≥ 48 e dominância), não mais no meio do gesto.
|
||||
- `onSwipePreviewStart(direction)` — no claim (12 px + dominância, no `touchmove` não-passivo): direção **travada** aqui; dispara também o `touchcancel` sintético (movido do commit para o claim — mata o timer de long-press do FC no momento em que o gesto é nosso).
|
||||
- `onSwipePreviewEnd()` — fim sem commit (solta < 48 px, dominância vertical, `blockRef`, `pointercancel`).
|
||||
- Transform imperativo no `containerRef`: a partir do claim, `translateX(dx)` com **clamp na direção travada** (o grid nunca cruza o ponto de partida); snap-back/commit = classe de transição + reset para 0 (transitionend/fallback limpa). Reduz-movimento: a regra CSS existente zera a transição.
|
||||
- **`ActivityAgenda.tsx`**: estado `swipePreview: { direction } | null` (liga no `previewStart`, desliga no `previewEnd`), `swipePreviewEvents` + cache `Map` por `start|end|direction` num ref (limpo quando `visibleRange` muda) + guard de chave ativa contra race de fetch; no previewStart: `loadActivityAgendaEvents({ ...state, rangeStart, rangeEnd })` com o range adjacente (mesma action/filtros do grid — access intacto). Commit: `api.next()/prev()` como hoje.
|
||||
- **`ActivityAgendaSwipePreview.tsx`** (novo, `src/components/campaign/activity/`): painel `aria-hidden` filho do container transformado, `position: absolute; inset-block: 0; width: 100%; left: 100%` (next) / `right: 100%` (prev). Conteúdo na costura (lado do painel que encosta no grid): chevron (lucide), label do período adjacente, frame skeleton por vista (dia/semana: linha de cabeçalho com iniciais dos dias + separadores de coluna/faixa; mês: grid de células sutil; lista: label + linhas skeleton) e **barras de evento** aproximadas por proporção de tempo 07:00–22:00 (all-day → faixa do topo; mês → pontos por célula; lista → linhas com título). Aproximação assumida (preview ≠ pixel-perfect); fallback Opção 2 (só chevron+label) se destoar demais — guarda do aceite, validada no browser.
|
||||
- **`ActivityAgenda.css`**: `.activity-agenda { position: relative }` (containing block do painel), painel/frame/barras, `will-change: transform` durante o drag (classe do hook), transição de saída, e o container entra na regra `prefers-reduced-motion` (hoje ela cobre só descendentes).
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum novo (mesma server action autenticada do grid).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- N/A para o grid (mesmo dado de hoje); a "forma" nova é o preview: quadro abstrato do grid (não números) + chevron + barras aproximadas — restrição de produto da intenção ("mostra o quadro do grid, não números").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Helpers puros** — `activityAgendaAdjacentPeriod` + unit tests (dia/semana/mês cruzando ano, lista, ano bissexto, direção prev; paridade: `activityAgendaPeriodLabel(adjacente)` retorna o rótulo esperado pós-commit).
|
||||
2. **Hook rework** — contrato novo + unit tests (claim → previewStart; solta < limiar → previewEnd sem onSwipe e transform resetado; ≥ limiar → onSwipe uma vez; dominância vertical → abandona + reset; blockRef; pointercancel; direção travada + clamp; pen; mouse ignorado; enabled=false; suppressDateClickRef inalterado: armado no claim, limpo no próximo pointerdown).
|
||||
3. **Preview + integração (tracer)** — painel + frame + barras + cache/fetch; browser: transform segue o dedo, preview na faixa revelada, commit navega, snap-back volta.
|
||||
4. **Impeccable C (shape → craft → critique → polish)** — localizado na superfície mobile da agenda: chevron (seguir canvas: aponta para o lado revelado; critique re-checa "direção da mudança" do aceite e pode flippar), frames por vista, barras, slide do commit, sensação do snap-back.
|
||||
5. **E2E + gates** — adaptar `touchSwipe` (o commit agora é no soltar — o contrato de resultado é o mesmo), novos testes: snap-back (swipe < 48 px: título não muda, transform volta a zero), preview no meio do gesto (gesto pausado via CDP: painel + chevron visíveis; eventos do adjacente entram no preview com `expect.poll`), commit ≥ limiar; `pnpm gate:fast` na iteração; `pnpm push` na entrega.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Segundo FullCalendar vivo + preload (B da Opção D1 — o aceite cortou).
|
||||
- Preview pixel-perfect (posição das barras é proporção aproximada; validar no browser se "destoa" → Opção 2).
|
||||
- Reavaliação de direção no meio do gesto (travada no claim — nativo reavalia, mas aqui é cosmético e custaria fetch/render duplos; gatilho de revisita se o feel pedir).
|
||||
- Animar o título do período no header (anti-goal da intenção — atualiza no commit, como hoje).
|
||||
- Transições de criação inline / remanejo / redimensionamento; ano no rótulo do mês; desktop.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **dateClick do FC no grid deslocado** (vista de semana, pointerup sobre o grid antigo deslocado antes do `api.next()`): o click é engolido pela `suppressDateClickRef` já armada no claim (contrato existente; o e2e "swipe não abre inline create" pina).
|
||||
- **FC com filho absoluto extra**: FC renderiza a própria raiz e não varre irmãos — verificar no browser (o painel é `aria-hidden` e não participa do layout).
|
||||
- **Ordem touchend (FC) vs pointerup (janela)**: FC processa o toque antes do nosso commit; dateClick do grid antigo → engolido (acima). O `api.next()` no pointerup renderiza o período novo no container em transição → slide de entrada lido como transição suave; se o efeito ficar estranho, degrade para troca instantânea (aceite permite — "resultado final é o de hoje").
|
||||
- **Mudança de comportamento documentada**: touchcancel no claim mata o timer de long-press do FC aos 12 px — um arrasto horizontal lento (12–47 px) sobre evento não inicia mais remanejo (hoje começava se passasse dos 650 ms antes dos 48 px). Remanejo (C15) continua intacto via hold-650ms-depois-arrasta (o `blockRef` vence antes do claim). Registrar como débito leve? Não — comportamento intencional e mais consistente (gesto horizontal = swipe).
|
||||
- **Fetch durante o arrasto**: mesma action autenticada do grid; range ≤ 45d ✓; guard de chave ativa contra race; cache Map evita refetch ao ir e voltar.
|
||||
- **Re-render**: transform imperativo no hook → FC não re-renderiza por frame; preview vira estado só nos eventos discretos.
|
||||
- **E2E CDP**: observar o meio do gesto exige dispatches pausados (touchStart + moves → avaliar DOM → moves → touchEnd); o gesto pausado ~125 ms é idêntico ao padrão atual do helper.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (grid segue o dedo; adjacente revelado com chevron + label + quadro; eventos assíncronos no preview; snap-back < 48 px; commit ≥ 48 px com transição suave; reduced-motion instantâneo; desktop, tap C91 e long-press C15 intactos; vista de dia sem create pós-arrasto)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem migration/sem prod/sem Consent novo; copy pt-BR / identificadores em inglês; hook dono do gesto com contrato unit-testado; sem twin de componentes)
|
||||
- [ ] Testes de domínio: unit do helper adjacente + label parity; unit do hook (contrato novo); e2e mobile (snap-back, preview mid-drag com eventos, commit) + existentes verdes; gate:fast e gate:push verdes
|
||||
|
|
@ -45,6 +45,178 @@
|
|||
event drag it preventDefaults window touchmoves (its touchScrollAllowed
|
||||
cancellation), which stops the browser's pan claim. */
|
||||
touch-action: pan-y;
|
||||
/* C110 — the adjacent-period preview is positioned against the grid's own
|
||||
edge (left:100% / right:100% of this container), which only works with a
|
||||
containing block here. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* C110 — the live drag: the grid is directly manipulated by the finger (the
|
||||
transform is imperative, written by the swipe hook — no React state at
|
||||
60fps) and the release settles it with a short exit slide (commit) or the
|
||||
snap-back. */
|
||||
.activity-agenda.activity-agenda-swipe-dragging {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.activity-agenda.activity-agenda-swipe-exit {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* C110 — the adjacent-period reveal: a child of the transformed grid
|
||||
container, glued to the seam. It rides the same transform as the grid (it
|
||||
is a child), so the strip that opens at the grid's leading edge shows the
|
||||
preview's seam edge — no dx bookkeeping in JS. */
|
||||
.activity-agenda-swipe-preview {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-preview--next {
|
||||
left: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-preview--prev {
|
||||
right: 100%;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-preview-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-preview--next .activity-agenda-swipe-preview-content {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-preview--prev .activity-agenda-swipe-preview-content {
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-chevron {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The abstract grid frame ("quadro do grid"): a week/day header plus faint
|
||||
column/time bands. Not a second FullCalendar — deliberately a skeleton. */
|
||||
.activity-agenda-swipe-frame {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
opacity: 0.55;
|
||||
background-image: repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
transparent calc(14.285% - 1px),
|
||||
var(--border) calc(14.285% - 1px),
|
||||
var(--border) 14.285%
|
||||
);
|
||||
background-size: 100% 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-frame[data-view='day'] {
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent calc(20% - 1px),
|
||||
var(--border) calc(20% - 1px),
|
||||
var(--border) 20%
|
||||
);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-frame[data-view='month'] {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
transparent calc(14.285% - 1px),
|
||||
var(--border) calc(14.285% - 1px),
|
||||
var(--border) 14.285%
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent calc(16.66% - 1px),
|
||||
var(--border) calc(16.66% - 1px),
|
||||
var(--border) 16.66%
|
||||
);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-frame-head {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-frame-head span {
|
||||
flex: 1;
|
||||
padding: 0.1875rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-frame-head span + span {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* C110 — the adjacent period's events enter the preview as they load (the
|
||||
accept's async path): bars by time proportion for day/week, dots per date
|
||||
for month, rows for the list. Approximate on purpose — the commit renders
|
||||
the real grid. */
|
||||
.activity-agenda-swipe-event {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 62%;
|
||||
height: 0.3125rem;
|
||||
border-radius: 0.1875rem;
|
||||
background: color-mix(in oklab, var(--primary) 72%, transparent);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-dot {
|
||||
position: absolute;
|
||||
width: 9%;
|
||||
height: 0.375rem;
|
||||
border-radius: 0.1875rem;
|
||||
background: color-mix(in oklab, var(--primary) 72%, transparent);
|
||||
}
|
||||
|
||||
.activity-agenda-swipe-list-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0.3125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in oklab, var(--foreground) 7%, transparent);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-agenda button {
|
||||
|
|
@ -220,6 +392,7 @@
|
|||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activity-agenda,
|
||||
.activity-agenda *,
|
||||
.activity-agenda *::before,
|
||||
.activity-agenda *::after {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import {
|
|||
loadActivityAgendaEvents,
|
||||
rescheduleActivity,
|
||||
} from '@/app/(campaign)/campanha/actions/activity'
|
||||
import {
|
||||
ActivityAgendaSwipePreview,
|
||||
type ActivityAgendaAdjacentRange,
|
||||
} from '@/components/campaign/activity/ActivityAgendaSwipePreview'
|
||||
import {
|
||||
ActivityInlineCreate,
|
||||
type ActivityInlineCreateDraft,
|
||||
|
|
@ -47,6 +51,7 @@ import {
|
|||
type ActivityStatus,
|
||||
} from '@/lib/schemas/activity'
|
||||
import {
|
||||
activityAgendaAdjacentPeriod,
|
||||
activityAgendaPeriodLabel,
|
||||
activityAgendaViewFcId,
|
||||
activityAgendaViewFromFcId,
|
||||
|
|
@ -176,6 +181,12 @@ export const ActivityAgenda = ({
|
|||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [isNarrow, setIsNarrow] = useState(false)
|
||||
const [createDraft, setCreateDraft] = useState<ActivityInlineCreateDraft | null>(null)
|
||||
// C110 — the adjacent-period reveal: which side is open and the events that
|
||||
// load (async, during the drag) for that period.
|
||||
const [swipePreview, setSwipePreview] = useState<{ direction: 'next' | 'prev' } | null>(null)
|
||||
const [swipePreviewEvents, setSwipePreviewEvents] = useState<ActivityAgendaEvent[]>([])
|
||||
const swipePreviewKeyRef = useRef<string | null>(null)
|
||||
const adjacentEventsCacheRef = useRef(new Map<string, ActivityAgendaEvent[]>())
|
||||
|
||||
// C101 — the mobile behaviors key on the WINDOW (<768, the app top bar's
|
||||
// own `md:hidden` breakpoint), not on the calendar container: a desktop
|
||||
|
|
@ -302,6 +313,13 @@ export const ActivityAgenda = ({
|
|||
)
|
||||
}, [visibleRange])
|
||||
|
||||
// C110 — the adjacent range the open preview reveals (pure derivation from
|
||||
// the visible range; null when the preview has no range to show).
|
||||
const adjacentPreviewRange = useMemo<ActivityAgendaAdjacentRange | null>(() => {
|
||||
if (!swipePreview || !visibleRange) return null
|
||||
return activityAgendaAdjacentPeriod(visibleRange.view, visibleRange, swipePreview.direction)
|
||||
}, [swipePreview, visibleRange])
|
||||
|
||||
const handleToday = useCallback(() => {
|
||||
const api = calendarRef.current?.getApi()
|
||||
if (!api) return
|
||||
|
|
@ -344,11 +362,63 @@ export const ActivityAgenda = ({
|
|||
onNavigate: handlePeriodNavigation,
|
||||
})
|
||||
|
||||
// C110 — the preview opens at the 12px claim with the direction locked; the
|
||||
// adjacent period's events load asynchronously (never blocking the gesture)
|
||||
// with a per-visible-range cache so flicking back and forth does not refetch.
|
||||
const handleSwipePreviewStart = useCallback(
|
||||
(direction: 'next' | 'prev') => {
|
||||
if (!visibleRange) return
|
||||
const adjacent = activityAgendaAdjacentPeriod(visibleRange.view, visibleRange, direction)
|
||||
if (!adjacent) return
|
||||
const key = `${direction}:${visibleRange.start}|${visibleRange.end}`
|
||||
swipePreviewKeyRef.current = key
|
||||
setSwipePreview({ direction })
|
||||
const cached = adjacentEventsCacheRef.current.get(key)
|
||||
if (cached) {
|
||||
setSwipePreviewEvents(cached)
|
||||
return
|
||||
}
|
||||
setSwipePreviewEvents([])
|
||||
void loadActivityAgendaEvents({
|
||||
...state,
|
||||
rangeStart: adjacent.start,
|
||||
rangeEnd: adjacent.end,
|
||||
})
|
||||
.then((loadedEvents) => {
|
||||
// C110 — only the ACTIVE preview may consume the result: a fetch from
|
||||
// a previous gesture resolving late must not populate a preview for
|
||||
// the other side (or after the preview already closed).
|
||||
if (swipePreviewKeyRef.current !== key) return
|
||||
adjacentEventsCacheRef.current.set(key, loadedEvents)
|
||||
setSwipePreviewEvents(loadedEvents)
|
||||
})
|
||||
.catch(() => {
|
||||
// C110 — the preview degrades to the empty frame; the commit still
|
||||
// loads the real grid through the normal datesSet path.
|
||||
})
|
||||
},
|
||||
[state, visibleRange],
|
||||
)
|
||||
|
||||
const handleSwipePreviewEnd = useCallback(() => {
|
||||
swipePreviewKeyRef.current = null
|
||||
setSwipePreview(null)
|
||||
setSwipePreviewEvents([])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// C110 — a commit/today invalidates the cache: its entries describe the
|
||||
// previous visible range.
|
||||
adjacentEventsCacheRef.current.clear()
|
||||
}, [visibleRange])
|
||||
|
||||
const { suppressDateClickRef } = useAgendaSwipeNavigation({
|
||||
containerRef,
|
||||
enabled: isMobile,
|
||||
blockRef: draggingEventRef,
|
||||
onSwipe: handlePeriodNavigation,
|
||||
onSwipePreviewStart: handleSwipePreviewStart,
|
||||
onSwipePreviewEnd: handleSwipePreviewEnd,
|
||||
})
|
||||
|
||||
const handleDateClick = useCallback(
|
||||
|
|
@ -525,6 +595,15 @@ export const ActivityAgenda = ({
|
|||
noEventsText="Nenhum compromisso nesta janela"
|
||||
headingLevel={2}
|
||||
/>
|
||||
|
||||
{swipePreview && visibleRange && adjacentPreviewRange ? (
|
||||
<ActivityAgendaSwipePreview
|
||||
view={visibleRange.view}
|
||||
direction={swipePreview.direction}
|
||||
range={adjacentPreviewRange}
|
||||
events={swipePreviewEvents}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ActivityInlineCreate
|
||||
|
|
|
|||
135
src/components/campaign/activity/ActivityAgendaSwipePreview.tsx
Normal file
135
src/components/campaign/activity/ActivityAgendaSwipePreview.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
import {
|
||||
civilDateDaysBetween,
|
||||
formatBahiaCivilDate,
|
||||
formatIsoAsBahiaDateTimeInput,
|
||||
} from '@/lib/campaignTime'
|
||||
import { activityAgendaPeriodLabel, type ActivityAgendaView } from '@/utilities/activityUi'
|
||||
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
|
||||
|
||||
/**
|
||||
* C110 — the adjacent-period reveal: while the finger drags the grid, this
|
||||
* panel sits glued to the seam (a child of the transformed container, at
|
||||
* `left: 100%` for next / `right: 100%` for prev) and its edge slides into
|
||||
* the revealed strip. It shows the direction chevron, the adjacent period
|
||||
* label and an abstract grid frame (the "quadro do grid" of the accept —
|
||||
* deliberately NOT a second FullCalendar, which is a cut rabbit hole), with
|
||||
* the adjacent period's events entering as bars/dots/rows as they load
|
||||
* asynchronously. `aria-hidden`: the gesture is the interaction; the commit
|
||||
* replaces the whole preview with the real grid.
|
||||
*/
|
||||
const SLOT_START_MINUTES = 7 * 60
|
||||
const SLOT_END_MINUTES = 22 * 60
|
||||
const MAX_PREVIEW_EVENTS = 6
|
||||
|
||||
const weekInitials = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'] as const
|
||||
|
||||
export type ActivityAgendaAdjacentRange = {
|
||||
start: string
|
||||
end: string
|
||||
anchorDate: string
|
||||
}
|
||||
|
||||
const timedTopPercent = (startAt: string): number => {
|
||||
const civil = formatIsoAsBahiaDateTimeInput(startAt)
|
||||
const match = /T(\d{2}):(\d{2})/.exec(civil)
|
||||
if (!match) return 0
|
||||
const minutes = Number(match[1]) * 60 + Number(match[2])
|
||||
const clamped = Math.min(
|
||||
Math.max(minutes - SLOT_START_MINUTES, 0),
|
||||
SLOT_END_MINUTES - SLOT_START_MINUTES,
|
||||
)
|
||||
return (clamped / (SLOT_END_MINUTES - SLOT_START_MINUTES)) * 100
|
||||
}
|
||||
|
||||
const monthCellPosition = (
|
||||
rangeStart: string,
|
||||
startAt: string,
|
||||
): { column: number; row: number } | null => {
|
||||
const startCivil = formatBahiaCivilDate(new Date(rangeStart))
|
||||
const eventCivil = formatBahiaCivilDate(new Date(startAt))
|
||||
const dayIndex = civilDateDaysBetween(startCivil, eventCivil)
|
||||
if (dayIndex < 0 || dayIndex > 41) return null
|
||||
return { column: dayIndex % 7, row: Math.floor(dayIndex / 7) }
|
||||
}
|
||||
|
||||
export const ActivityAgendaSwipePreview = ({
|
||||
view,
|
||||
direction,
|
||||
range,
|
||||
events,
|
||||
}: {
|
||||
view: ActivityAgendaView
|
||||
direction: 'next' | 'prev'
|
||||
range: ActivityAgendaAdjacentRange
|
||||
events: ActivityAgendaEvent[]
|
||||
}) => {
|
||||
const label = activityAgendaPeriodLabel(view, range.start, range.end, range.anchorDate)
|
||||
const seam =
|
||||
direction === 'next'
|
||||
? 'activity-agenda-swipe-preview--next'
|
||||
: 'activity-agenda-swipe-preview--prev'
|
||||
const visibleEvents = events.slice(0, MAX_PREVIEW_EVENTS)
|
||||
|
||||
return (
|
||||
<div className={`activity-agenda-swipe-preview ${seam}`} aria-hidden="true">
|
||||
<div className="activity-agenda-swipe-preview-content">
|
||||
{direction === 'next' ? (
|
||||
<ChevronRight className="activity-agenda-swipe-chevron" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronLeft className="activity-agenda-swipe-chevron" aria-hidden="true" />
|
||||
)}
|
||||
{label ? <span className="activity-agenda-swipe-label">{label}</span> : null}
|
||||
|
||||
{/* C110 — the abstract grid frame; the list view has no columns to
|
||||
suggest, so it degrades to chevron + label + event rows (the
|
||||
accepted degradation of the intention). */}
|
||||
{view !== 'list' ? (
|
||||
<div className="activity-agenda-swipe-frame" data-view={view}>
|
||||
{view === 'week' ? (
|
||||
<div className="activity-agenda-swipe-frame-head">
|
||||
{weekInitials.map((initial) => (
|
||||
<span key={initial}>{initial}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleEvents.map((event) => {
|
||||
if (view === 'month') {
|
||||
const cell = monthCellPosition(range.start, event.startAt)
|
||||
if (!cell) return null
|
||||
return (
|
||||
<span
|
||||
key={event.id}
|
||||
className="activity-agenda-swipe-dot"
|
||||
style={{
|
||||
left: `${cell.column * (100 / 7)}%`,
|
||||
top: `${cell.row * (100 / 6)}%`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (view === 'list') {
|
||||
return (
|
||||
<span key={event.id} className="activity-agenda-swipe-list-row">
|
||||
{event.title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={event.id}
|
||||
className="activity-agenda-swipe-event"
|
||||
style={event.allDay ? { top: '2%' } : { top: `${timedTopPercent(event.startAt)}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* C101 — horizontal swipe navigation for the mobile agenda. FullCalendar has
|
||||
* no swipe gesture of its own, so this hook listens for a clear horizontal
|
||||
* C101/C110 — horizontal swipe navigation for the mobile agenda. FullCalendar
|
||||
* has no swipe gesture of its own, so this hook listens for a clear horizontal
|
||||
* drag on the calendar container and maps it to prev/next navigation.
|
||||
*
|
||||
* Chromium's pan controller claims any touch drag for scrolling the moment
|
||||
|
|
@ -17,6 +17,25 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject }
|
|||
* preventDefault, so the timegrid keeps scrolling natively (and the browser
|
||||
* cancels the pointer stream — the gesture then resets, which is correct).
|
||||
*
|
||||
* C110 — the gesture now gives live feedback instead of swapping mid-drag:
|
||||
*
|
||||
* - At the 12px claim the grid starts following the finger (imperative
|
||||
* transform on the container — never React state, so FullCalendar does not
|
||||
* re-render per frame), the adjacent period preview starts
|
||||
* (`onSwipePreviewStart`), and a synthetic `touchcancel` is dispatched to
|
||||
* the touchstart target — browsers emit real touchcancel exactly when a
|
||||
* gesture is taken away, and FC treats it as a touch end, clearing its
|
||||
* pending long-press timers. The direction is LOCKED at the claim and the
|
||||
* transform is clamped to it (the grid never crosses its starting point).
|
||||
* Every subsequent touchmove keeps calling `preventDefault`: the browser's
|
||||
* pan/flick recognizers re-evaluate on each move, and one unprevented move
|
||||
* mid-drag fires a real pointercancel (the stream dies, taking the live
|
||||
* transform and the preview with it).
|
||||
* - The navigation decision moved to the RELEASE: `pointerup` with
|
||||
* |dx| ≥ 48px (and still horizontal) commits — `onSwipe(direction)` — with
|
||||
* the grid sliding the remaining distance to rest; anything below commits
|
||||
* as a snap-back (transform reset, no navigation, `onSwipePreviewEnd`).
|
||||
*
|
||||
* Contract with FullCalendar's touch interactions (FC runs on touch events,
|
||||
* this hook on pointer events, both on the same gesture):
|
||||
*
|
||||
|
|
@ -30,9 +49,13 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject }
|
|||
* and clears on the next pointerdown — a genuine tap after a failed swipe
|
||||
* keeps working.
|
||||
* - The event long-press (650ms, C15 reschedule) must not fire mid-swipe:
|
||||
* on consume we dispatch a synthetic `touchcancel` at the touchstart
|
||||
* target — browsers emit real touchcancel exactly when a gesture is taken
|
||||
* away, and FC treats it as a touch end, clearing its pending timers.
|
||||
* the synthetic `touchcancel` at the claim kills it as soon as the gesture
|
||||
* is ours. A reschedule still wins when the user holds still first (the
|
||||
* long-press fires before any claim and `blockRef` then abandons the
|
||||
* swipe). Behavior change vs C101: a slow horizontal drag (12–47px) on an
|
||||
* event no longer falls through to FC's long-press at 650ms — by then the
|
||||
* claim has already canceled it — it snap-backs instead. Intentional: any
|
||||
* claimed horizontal gesture is swipe territory.
|
||||
* - A drag FC already owns (eventDragStart/eventResizeStart fired) is
|
||||
* blocked via `blockRef`: slow horizontal drags on events stay
|
||||
* reschedules.
|
||||
|
|
@ -46,6 +69,8 @@ const AGENDA_SWIPE_DOMINANCE = 1.4
|
|||
const AGENDA_SWIPE_CLAIM_PX = 12
|
||||
/** Backstop for the dateClick suppression when no next gesture ever comes. */
|
||||
const AGENDA_CLICK_SUPPRESSION_MS = 500
|
||||
/** Exit animation (commit slide / snap-back), and its cleanup backstop. */
|
||||
const AGENDA_SWIPE_EXIT_MS = 180
|
||||
|
||||
type AgendaSwipeDirection = 'next' | 'prev'
|
||||
|
||||
|
|
@ -54,7 +79,10 @@ type AgendaSwipeGesture = {
|
|||
startX: number
|
||||
startY: number
|
||||
startTarget: EventTarget | null
|
||||
consumed: boolean
|
||||
/** Locked at the claim; drives the preview side and the transform clamp. */
|
||||
direction: AgendaSwipeDirection | null
|
||||
lastDx: number
|
||||
lastDy: number
|
||||
}
|
||||
|
||||
export const useAgendaSwipeNavigation = ({
|
||||
|
|
@ -62,19 +90,29 @@ export const useAgendaSwipeNavigation = ({
|
|||
enabled,
|
||||
blockRef,
|
||||
onSwipe,
|
||||
onSwipePreviewStart,
|
||||
onSwipePreviewEnd,
|
||||
}: {
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
/** Render-time gate (viewport/state-driven): when false, no gesture starts. */
|
||||
enabled: boolean
|
||||
/** Event-time gate: when true, an in-flight gesture is abandoned (FC drag owns the pointer). */
|
||||
blockRef: MutableRefObject<boolean>
|
||||
/** C110 — fired only when the release COMMITS (|dx| ≥ threshold, horizontal). */
|
||||
onSwipe: (direction: AgendaSwipeDirection) => void
|
||||
/** C110 — the adjacent-period preview opens (claim) with the locked direction. */
|
||||
onSwipePreviewStart?: (direction: AgendaSwipeDirection) => void
|
||||
/** C110 — the preview closes without navigation (snap-back, abandon, cancel). */
|
||||
onSwipePreviewEnd?: () => void
|
||||
}): { suppressDateClickRef: MutableRefObject<boolean> } => {
|
||||
const gestureRef = useRef<AgendaSwipeGesture | null>(null)
|
||||
const enabledRef = useRef(enabled)
|
||||
const blockRefRef = useRef(blockRef)
|
||||
const onSwipeRef = useRef(onSwipe)
|
||||
const onSwipePreviewStartRef = useRef(onSwipePreviewStart)
|
||||
const onSwipePreviewEndRef = useRef(onSwipePreviewEnd)
|
||||
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/**
|
||||
* The day view has a single column, so a horizontal drag never leaves the
|
||||
* slot it started on: FullCalendar's dateClick fires (initialHit ===
|
||||
|
|
@ -87,6 +125,8 @@ export const useAgendaSwipeNavigation = ({
|
|||
enabledRef.current = enabled
|
||||
blockRefRef.current = blockRef
|
||||
onSwipeRef.current = onSwipe
|
||||
onSwipePreviewStartRef.current = onSwipePreviewStart
|
||||
onSwipePreviewEndRef.current = onSwipePreviewEnd
|
||||
|
||||
const armClickSuppression = useCallback(() => {
|
||||
suppressDateClickRef.current = true
|
||||
|
|
@ -97,75 +137,91 @@ export const useAgendaSwipeNavigation = ({
|
|||
}, AGENDA_CLICK_SUPPRESSION_MS)
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = useCallback((event: PointerEvent) => {
|
||||
if (!enabledRef.current || blockRefRef.current.current) return
|
||||
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return
|
||||
if (event.isPrimary === false) return
|
||||
|
||||
// A new gesture means the previous one ended: its dateClick (if any) has
|
||||
// already fired, so a genuine tap right after a failed swipe is safe.
|
||||
suppressDateClickRef.current = false
|
||||
if (suppressTimerRef.current) {
|
||||
clearTimeout(suppressTimerRef.current)
|
||||
suppressTimerRef.current = null
|
||||
const clearPendingExit = useCallback(() => {
|
||||
if (exitTimerRef.current) {
|
||||
clearTimeout(exitTimerRef.current)
|
||||
exitTimerRef.current = null
|
||||
}
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
container.classList.remove('activity-agenda-swipe-dragging', 'activity-agenda-swipe-exit')
|
||||
}, [containerRef])
|
||||
|
||||
gestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startTarget: event.target,
|
||||
consumed: false,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || gesture.consumed || blockRefRef.current.current) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
|
||||
const deltaX = touch.clientX - gesture.startX
|
||||
const deltaY = touch.clientY - gesture.startY
|
||||
if (
|
||||
Math.abs(deltaX) > AGENDA_SWIPE_CLAIM_PX &&
|
||||
Math.abs(deltaX) > Math.abs(deltaY) * AGENDA_SWIPE_DOMINANCE
|
||||
) {
|
||||
// Clearly horizontal: keep the browser from claiming the gesture for
|
||||
// panning (which would pointercancel the stream before the
|
||||
// threshold) AND suppress the dateClick a sub-threshold flick would
|
||||
// otherwise trigger in the day view.
|
||||
event.preventDefault()
|
||||
armClickSuppression()
|
||||
}
|
||||
/** C110 — the live transform: follows the finger, clamped to the claimed
|
||||
* direction (the grid never crosses its starting point). */
|
||||
const applyTransform = useCallback(
|
||||
(dx: number, direction: AgendaSwipeDirection | null) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const clamped = direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0)
|
||||
container.style.transform = clamped === 0 ? '' : `translateX(${clamped}px)`
|
||||
},
|
||||
[armClickSuppression],
|
||||
[containerRef],
|
||||
)
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
/** C110 — release: animate the grid back to rest (commit slide or snap-back). */
|
||||
const settleTransform = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
container.classList.remove('activity-agenda-swipe-dragging')
|
||||
container.classList.add('activity-agenda-swipe-exit')
|
||||
container.style.transform = ''
|
||||
if (exitTimerRef.current) clearTimeout(exitTimerRef.current)
|
||||
exitTimerRef.current = setTimeout(() => {
|
||||
exitTimerRef.current = null
|
||||
container.classList.remove('activity-agenda-swipe-exit')
|
||||
}, AGENDA_SWIPE_EXIT_MS + 100)
|
||||
}, [containerRef])
|
||||
|
||||
/** C110 — end a claimed gesture without committing: preview closes, grid settles. */
|
||||
const endWithoutCommit = useCallback(() => {
|
||||
settleTransform()
|
||||
onSwipePreviewEndRef.current?.()
|
||||
}, [settleTransform])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || gesture.consumed || event.pointerId !== gesture.pointerId) return
|
||||
if (blockRefRef.current.current) {
|
||||
gestureRef.current = null
|
||||
return
|
||||
if (!enabledRef.current || blockRefRef.current.current) return
|
||||
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return
|
||||
if (event.isPrimary === false) return
|
||||
|
||||
// A new gesture means the previous one ended: its dateClick (if any) has
|
||||
// already fired, so a genuine tap right after a failed swipe is safe.
|
||||
// Any in-flight exit animation belongs to the previous gesture — the
|
||||
// new one owns the transform from here.
|
||||
suppressDateClickRef.current = false
|
||||
if (suppressTimerRef.current) {
|
||||
clearTimeout(suppressTimerRef.current)
|
||||
suppressTimerRef.current = null
|
||||
}
|
||||
clearPendingExit()
|
||||
|
||||
const deltaX = event.clientX - gesture.startX
|
||||
const deltaY = event.clientY - gesture.startY
|
||||
if (Math.abs(deltaX) < AGENDA_SWIPE_THRESHOLD_PX) return
|
||||
if (Math.abs(deltaX) < Math.abs(deltaY) * AGENDA_SWIPE_DOMINANCE) {
|
||||
// Vertical drag (scrolling the timeline, rescheduling): never navigate.
|
||||
gesture.consumed = true
|
||||
return
|
||||
gestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startTarget: event.target,
|
||||
direction: null,
|
||||
lastDx: 0,
|
||||
lastDy: 0,
|
||||
}
|
||||
},
|
||||
[clearPendingExit],
|
||||
)
|
||||
|
||||
gesture.consumed = true
|
||||
/** C110 — the 12px claim lives in the non-passive touchmove (the same spot
|
||||
* the preventDefault must happen): lock the direction, start the preview,
|
||||
* arm the click suppression and kill FullCalendar's pending timers. */
|
||||
const claimGesture = useCallback(
|
||||
(gesture: AgendaSwipeGesture, dx: number, dy: number) => {
|
||||
gesture.direction = dx < 0 ? 'next' : 'prev'
|
||||
gesture.lastDx = dx
|
||||
gesture.lastDy = dy
|
||||
armClickSuppression()
|
||||
|
||||
// Kill FullCalendar's pending long-press/tap timers FIRST: its touch
|
||||
// listeners are attached to the touchstart target, so the cancel must be
|
||||
// dispatched there while that node is still connected (synthetic
|
||||
// Kill FullCalendar's pending long-press/tap timers: its touch
|
||||
// listeners are attached to the touchstart target, so the cancel must
|
||||
// be dispatched there while that node is still connected (synthetic
|
||||
// touchcancel = the browser taking the gesture away).
|
||||
const startTarget = gesture.startTarget
|
||||
if (typeof TouchEvent !== 'undefined' && startTarget instanceof Element) {
|
||||
|
|
@ -174,17 +230,120 @@ export const useAgendaSwipeNavigation = ({
|
|||
)
|
||||
}
|
||||
|
||||
armClickSuppression()
|
||||
onSwipeRef.current(deltaX < 0 ? 'next' : 'prev')
|
||||
containerRef.current?.classList.add('activity-agenda-swipe-dragging')
|
||||
applyTransform(dx, gesture.direction)
|
||||
onSwipePreviewStartRef.current?.(gesture.direction)
|
||||
},
|
||||
[armClickSuppression],
|
||||
[applyTransform, armClickSuppression, containerRef],
|
||||
)
|
||||
|
||||
const handlePointerEnd = useCallback((event: PointerEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || event.pointerId !== gesture.pointerId) return
|
||||
gestureRef.current = null
|
||||
}, [])
|
||||
const handleTouchMove = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || blockRefRef.current.current) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
|
||||
const dx = touch.clientX - gesture.startX
|
||||
const dy = touch.clientY - gesture.startY
|
||||
if (gesture.direction === null) {
|
||||
if (
|
||||
Math.abs(dx) > AGENDA_SWIPE_CLAIM_PX &&
|
||||
Math.abs(dx) > Math.abs(dy) * AGENDA_SWIPE_DOMINANCE
|
||||
) {
|
||||
// Clearly horizontal: keep the browser from claiming the gesture
|
||||
// for panning (which would pointercancel the stream before the
|
||||
// threshold) AND suppress the dateClick a sub-threshold flick would
|
||||
// otherwise trigger in the day view.
|
||||
event.preventDefault()
|
||||
claimGesture(gesture, dx, dy)
|
||||
}
|
||||
return
|
||||
}
|
||||
// C110 — already claimed: keep holding the gesture. The preventDefault
|
||||
// must repeat on EVERY move, not just the claim: the browser's
|
||||
// pan/flick recognizers re-evaluate on each touchmove, and an
|
||||
// unprevented move mid-drag fires a real pointercancel — killing the
|
||||
// live transform and the preview. (C101 got away with claim-only
|
||||
// preventDefault because its commit ended the gesture within a move or
|
||||
// two; the release decision keeps the gesture alive through many.)
|
||||
event.preventDefault()
|
||||
applyTransform(dx, gesture.direction)
|
||||
},
|
||||
[applyTransform, claimGesture],
|
||||
)
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || event.pointerId !== gesture.pointerId) return
|
||||
if (blockRefRef.current.current) {
|
||||
if (gesture.direction !== null) endWithoutCommit()
|
||||
gestureRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const dx = event.clientX - gesture.startX
|
||||
const dy = event.clientY - gesture.startY
|
||||
gesture.lastDx = dx
|
||||
gesture.lastDy = dy
|
||||
|
||||
if (gesture.direction === null) {
|
||||
if (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) {
|
||||
// Vertical drag (scrolling the timeline, rescheduling): never claim.
|
||||
gestureRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) {
|
||||
// C110 — a claimed gesture turned vertical (scroll takeover): abandon,
|
||||
// snap back, close the preview. The browser is scrolling now — the
|
||||
// grid must not stay displaced.
|
||||
gestureRef.current = null
|
||||
endWithoutCommit()
|
||||
return
|
||||
}
|
||||
|
||||
applyTransform(dx, gesture.direction)
|
||||
},
|
||||
[applyTransform, endWithoutCommit],
|
||||
)
|
||||
|
||||
const handlePointerEnd = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || event.pointerId !== gesture.pointerId) return
|
||||
gestureRef.current = null
|
||||
|
||||
if (event.type === 'pointercancel') {
|
||||
if (gesture.direction !== null) endWithoutCommit()
|
||||
return
|
||||
}
|
||||
|
||||
// C110 — the decision is the RELEASE: commit when the drag is far and
|
||||
// still horizontal, snap back otherwise. The threshold measures the
|
||||
// grid's ACTUAL displacement (the clamp-locked position the finger
|
||||
// left the grid at), not the raw delta — a gesture that reversed past
|
||||
// its origin released an undisplaced grid and must snap back.
|
||||
if (gesture.direction !== null) {
|
||||
const dx = gesture.lastDx
|
||||
const dy = gesture.lastDy
|
||||
const displaced = gesture.direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0)
|
||||
if (
|
||||
Math.abs(displaced) >= AGENDA_SWIPE_THRESHOLD_PX &&
|
||||
Math.abs(dx) >= Math.abs(dy) * AGENDA_SWIPE_DOMINANCE
|
||||
) {
|
||||
settleTransform()
|
||||
onSwipePreviewEndRef.current?.()
|
||||
onSwipeRef.current(gesture.direction)
|
||||
return
|
||||
}
|
||||
endWithoutCommit()
|
||||
}
|
||||
},
|
||||
[endWithoutCommit, settleTransform],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
|
|
@ -213,6 +372,7 @@ export const useAgendaSwipeNavigation = ({
|
|||
useEffect(
|
||||
() => () => {
|
||||
if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current)
|
||||
if (exitTimerRef.current) clearTimeout(exitTimerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,112 @@ const ptBrMonthNames = [
|
|||
'Dezembro',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* C110 — the period the swipe preview reveals, mirroring FullCalendar's own
|
||||
* navigation arithmetic so the preview label (derived from this range via
|
||||
* `activityAgendaPeriodLabel`) matches the header title after the commit:
|
||||
* day shifts ±1 day, week ±7 days (Bahia has no DST, so instant ± 86.400.000 ms
|
||||
* is an exact civil day), month/list shift the anchor (a civil date of the
|
||||
* month's 1st) by one month. The month range spans the Monday on or before the
|
||||
* 1st through 6 full weeks (42 days — within the agenda's 45-day request cap,
|
||||
* which is all the adjacent fetch needs); the label only reads the anchor.
|
||||
* Returns null when the range cannot be derived (same fail-closed contract as
|
||||
* `activityAgendaPeriodLabel`).
|
||||
*/
|
||||
export const activityAgendaAdjacentPeriod = (
|
||||
view: ActivityAgendaView,
|
||||
range: { start: string; end: string; anchorDate: string },
|
||||
direction: 'next' | 'prev',
|
||||
): { start: string; end: string; anchorDate: string } | null => {
|
||||
const shiftDays = direction === 'next' ? 1 : -1
|
||||
const shiftMonths = direction === 'next' ? 1 : -1
|
||||
|
||||
const shiftRangeByDays = (days: number) => {
|
||||
const start = new Date(range.start)
|
||||
const end = new Date(range.end)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null
|
||||
const shiftedStart = new Date(start.getTime() + days * 86_400_000).toISOString()
|
||||
return {
|
||||
start: shiftedStart,
|
||||
end: new Date(end.getTime() + days * 86_400_000).toISOString(),
|
||||
anchorDate: formatBahiaCivilDate(new Date(start.getTime() + days * 86_400_000)),
|
||||
}
|
||||
}
|
||||
|
||||
if (view === 'day' || view === 'week') {
|
||||
return shiftRangeByDays(shiftDays * (view === 'day' ? 1 : 7))
|
||||
}
|
||||
|
||||
const anchor = civilDateAnchor(range.anchorDate)
|
||||
if (!anchor || anchor.day !== 1) return null
|
||||
|
||||
const shiftedAnchorParts = shiftCivilMonth(anchor.year, anchor.month, shiftMonths)
|
||||
const shiftedAnchor = `${shiftedAnchorParts.year}-${pad(shiftedAnchorParts.month)}-01`
|
||||
if (view === 'list') {
|
||||
const nextMonth = shiftCivilMonth(shiftedAnchorParts.year, shiftedAnchorParts.month, 1)
|
||||
return {
|
||||
start: civilInstantAtBahia(shiftedAnchor),
|
||||
end: civilInstantAtBahia(`${nextMonth.year}-${pad(nextMonth.month)}-01`),
|
||||
anchorDate: shiftedAnchor,
|
||||
}
|
||||
}
|
||||
|
||||
// month — the grid starts on the Monday (firstDay=1) on or before the 1st
|
||||
// and spans at most 6 weeks.
|
||||
const start = subtractBahiaCivilDays(shiftedAnchor, mondayOffsetOf(shiftedAnchor))
|
||||
return {
|
||||
start: civilInstantAtBahia(start),
|
||||
end: civilInstantAtBahia(subtractBahiaCivilDays(start, -42)),
|
||||
anchorDate: shiftedAnchor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Midnight of a Bahia civil date as a UTC instant. Bahia is fixed at UTC-3
|
||||
* (no DST since 2019), so the explicit `-03:00` offset is stable — the same
|
||||
* convention FullCalendar's own range strings use.
|
||||
*/
|
||||
const civilInstantAtBahia = (civilDate: string): string =>
|
||||
new Date(`${civilDate}T00:00:00-03:00`).toISOString()
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
/** Full civil-date anchor (`aaaa-mm-dd`) with day/month/year — the runtime
|
||||
* passes `formatBahiaCivilDate(getDate())`, so the year is always present. */
|
||||
const civilDateAnchor = (
|
||||
civilDate: string,
|
||||
): { year: number; month: number; day: number } | null => {
|
||||
const [year, month, day] = civilDate.split('-').map(Number)
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(day) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { year, month, day }
|
||||
}
|
||||
|
||||
const shiftCivilMonth = (
|
||||
year: number,
|
||||
month: number,
|
||||
months: number,
|
||||
): { year: number; month: number } => {
|
||||
const absolute = year * 12 + (month - 1) + months
|
||||
return { year: Math.floor(absolute / 12), month: (absolute % 12) + 1 }
|
||||
}
|
||||
|
||||
/** Weekday of a civil date counted from Monday (0 = Monday … 6 = Sunday). */
|
||||
const mondayOffsetOf = (civilDate: string): number => {
|
||||
const [year, month, day] = civilDate.split('-').map(Number)
|
||||
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
|
||||
return (weekday + 6) % 7
|
||||
}
|
||||
|
||||
const civilDateParts = (civilDate: string): { day: number; month: number } | null => {
|
||||
const [year, month, day] = civilDate.split('-').map(Number)
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -62,6 +62,32 @@ const touchSwipe = async (
|
|||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* C110 — like `touchSwipe`, but pauses mid-gesture (after the 12px claim) for
|
||||
* `inspect` to observe the live preview before the release decides.
|
||||
*/
|
||||
const touchDragWithInspection = async (
|
||||
cdp: CDPSession,
|
||||
box: { x: number; y: number; width: number; height: number },
|
||||
fromRatio: number,
|
||||
toRatio: number,
|
||||
inspect: () => Promise<void>,
|
||||
) => {
|
||||
const y = Math.round(box.y + box.height * 0.5)
|
||||
const x0 = Math.round(box.x + box.width * fromRatio)
|
||||
const x1 = Math.round(box.x + box.width * toRatio)
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: x0, y }] })
|
||||
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, 40))
|
||||
if (i === 2) await inspect()
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
}
|
||||
|
||||
const agendaSwipePreview = (page: Page) => page.locator('.activity-agenda-swipe-preview')
|
||||
|
||||
const tapAt = async (cdp: CDPSession, x: number, y: number) => {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] })
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
|
|
@ -347,3 +373,130 @@ test.describe('C101 — agenda mobile com cara de app nativo', () => {
|
|||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit/snap-back)', () => {
|
||||
test.setTimeout(90_000)
|
||||
|
||||
const seedTodayAndTomorrowActivities = async (
|
||||
campaign: CampaignE2EFixture,
|
||||
page: Page,
|
||||
): Promise<{ today: string; tomorrow: string }> => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const today = formatBahiaCivilDate(new Date())
|
||||
const tomorrow = civilDatePlusDays(today, 1)
|
||||
|
||||
for (const [civilDate, hour, title] of [
|
||||
[today, '10:00', 'Compromisso mobile hoje'],
|
||||
[tomorrow, '11:00', 'Compromisso mobile amanhã'],
|
||||
] as const) {
|
||||
const startAt = parseBahiaDateTimeInput(`${civilDate}T${hour}`)
|
||||
if (!startAt) throw new Error('Falha ao montar horário da fixture de agenda.')
|
||||
await fixtures.payload.create({
|
||||
collection: 'activity',
|
||||
data: hookFilledCreateData<'activity'>({
|
||||
title,
|
||||
tags: ['Comício'],
|
||||
status: 'confirmado',
|
||||
startAt,
|
||||
municipality: municipality.id,
|
||||
}),
|
||||
depth: 0,
|
||||
})
|
||||
}
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
return { today, tomorrow }
|
||||
}
|
||||
|
||||
test('o grid segue o dedo, o período adjacente é revelado e o soltar decide (commit)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { today, tomorrow } = await seedTodayAndTomorrowActivities(campaign, page)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
|
||||
const todayLabel = dayLabelFor(today)
|
||||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
const agendaBox = await page.locator('.activity-agenda').first().boundingBox()
|
||||
if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.')
|
||||
|
||||
// A meio do gesto (após o claim de 12px): o grid acompanhou o dedo e o
|
||||
// painel do período adjacente está revelado na borda — chevron, rótulo do
|
||||
// período seguinte e os eventos do adjacente entrando conforme carregam.
|
||||
await touchDragWithInspection(cdp, agendaBox, 0.8, 0.3, async () => {
|
||||
const preview = agendaSwipePreview(page)
|
||||
await expect(preview).toBeVisible()
|
||||
await expect(preview.locator('svg.activity-agenda-swipe-chevron')).toBeVisible()
|
||||
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(dayLabelFor(tomorrow))
|
||||
await expect
|
||||
.poll(
|
||||
() => preview.locator('.activity-agenda-swipe-event').count(),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
// O grid deslocado: o transform vivo acompanhou o dedo.
|
||||
const transform = await page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
|
||||
)
|
||||
expect(transform).not.toBe('none')
|
||||
})
|
||||
|
||||
// Soltou acima do limiar: o período adjacente assume a tela e o preview some.
|
||||
await expect(campaignPageChrome(page, dayLabelFor(tomorrow))).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(agendaSwipePreview(page)).toHaveCount(0)
|
||||
|
||||
// O commit não abre o inline create.
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('soltar abaixo do limiar volta ao período atual (snap-back) sem navegar', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { today } = await seedTodayAndTomorrowActivities(campaign, page)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
|
||||
const todayLabel = dayLabelFor(today)
|
||||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
const agendaBox = await page.locator('.activity-agenda').first().boundingBox()
|
||||
if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.')
|
||||
|
||||
// Arrasto de ~47px (0.9 → 0.78 em 390px): cruza o claim de 12px — o
|
||||
// preview abre — mas o soltar fica abaixo dos 48px do limiar.
|
||||
await touchDragWithInspection(cdp, agendaBox, 0.9, 0.78, async () => {
|
||||
const preview = agendaSwipePreview(page)
|
||||
await expect(preview).toBeVisible()
|
||||
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
|
||||
dayLabelFor(civilDatePlusDays(today, 1)),
|
||||
)
|
||||
})
|
||||
|
||||
// Snap-back: o título não muda e o grid voltou ao repouso.
|
||||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(agendaSwipePreview(page)).toHaveCount(0)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
|
||||
),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe('none')
|
||||
|
||||
// Nenhum inline create após o snap-back.
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { activityAgendaPeriodLabel, activityAgendaViewFromFcId } from '@/utilities/activityUi'
|
||||
import {
|
||||
activityAgendaAdjacentPeriod,
|
||||
activityAgendaPeriodLabel,
|
||||
activityAgendaViewFromFcId,
|
||||
} from '@/utilities/activityUi'
|
||||
|
||||
describe('activityAgendaPeriodLabel', () => {
|
||||
it('rotula o dia pelo dia e mês', () => {
|
||||
|
|
@ -111,6 +115,233 @@ describe('activityAgendaPeriodLabel', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('activityAgendaAdjacentPeriod', () => {
|
||||
it('desloca o dia em ±1 dia e ancora no dia civil deslocado', () => {
|
||||
expect(
|
||||
activityAgendaAdjacentPeriod(
|
||||
'day',
|
||||
{
|
||||
start: '2026-08-09T00:00:00-03:00',
|
||||
end: '2026-08-10T00:00:00-03:00',
|
||||
anchorDate: '2026-08-09',
|
||||
},
|
||||
'next',
|
||||
),
|
||||
).toEqual({
|
||||
start: '2026-08-10T03:00:00.000Z',
|
||||
end: '2026-08-11T03:00:00.000Z',
|
||||
anchorDate: '2026-08-10',
|
||||
})
|
||||
expect(
|
||||
activityAgendaAdjacentPeriod(
|
||||
'day',
|
||||
{
|
||||
start: '2026-08-09T00:00:00-03:00',
|
||||
end: '2026-08-10T00:00:00-03:00',
|
||||
anchorDate: '2026-08-09',
|
||||
},
|
||||
'prev',
|
||||
),
|
||||
).toEqual({
|
||||
start: '2026-08-08T03:00:00.000Z',
|
||||
end: '2026-08-09T03:00:00.000Z',
|
||||
anchorDate: '2026-08-08',
|
||||
})
|
||||
})
|
||||
|
||||
it('desloca a semana em ±7 dias preservando segunda a domingo', () => {
|
||||
expect(
|
||||
activityAgendaAdjacentPeriod(
|
||||
'week',
|
||||
{
|
||||
start: '2026-08-03T00:00:00-03:00',
|
||||
end: '2026-08-10T00:00:00-03:00',
|
||||
anchorDate: '2026-08-03',
|
||||
},
|
||||
'next',
|
||||
),
|
||||
).toEqual({
|
||||
start: '2026-08-10T03:00:00.000Z',
|
||||
end: '2026-08-17T03:00:00.000Z',
|
||||
anchorDate: '2026-08-10',
|
||||
})
|
||||
})
|
||||
|
||||
it('cruza o ano na semana e o dia (28 Dezembro → 4 Janeiro)', () => {
|
||||
const weekRange = {
|
||||
start: '2026-12-28T00:00:00-03:00',
|
||||
end: '2027-01-04T00:00:00-03:00',
|
||||
anchorDate: '2026-12-28',
|
||||
}
|
||||
const next = activityAgendaAdjacentPeriod('week', weekRange, 'next')
|
||||
expect(next).toEqual({
|
||||
start: '2027-01-04T03:00:00.000Z',
|
||||
end: '2027-01-11T03:00:00.000Z',
|
||||
anchorDate: '2027-01-04',
|
||||
})
|
||||
expect(activityAgendaPeriodLabel('week', next!.start, next!.end, next!.anchorDate)).toBe(
|
||||
'4–10 Janeiro',
|
||||
)
|
||||
})
|
||||
|
||||
it('desloca o mês a partir do anchor civil (1º do mês) com range de 42 dias', () => {
|
||||
const next = activityAgendaAdjacentPeriod(
|
||||
'month',
|
||||
{
|
||||
start: '2026-08-31T00:00:00-03:00',
|
||||
end: '2026-10-01T00:00:00-03:00',
|
||||
anchorDate: '2026-09-01',
|
||||
},
|
||||
'next',
|
||||
)
|
||||
// Outubro/2026: 1º é quinta → segunda que precede = 28 Setembro.
|
||||
expect(next).toEqual({
|
||||
start: '2026-09-28T03:00:00.000Z',
|
||||
end: '2026-11-09T03:00:00.000Z',
|
||||
anchorDate: '2026-10-01',
|
||||
})
|
||||
expect(activityAgendaPeriodLabel('month', next!.start, next!.end, next!.anchorDate)).toBe(
|
||||
'Outubro',
|
||||
)
|
||||
})
|
||||
|
||||
it('desloca o mês cruzando o ano e ajusta o grid à segunda antecedente', () => {
|
||||
const prev = activityAgendaAdjacentPeriod(
|
||||
'month',
|
||||
{
|
||||
start: '2026-12-28T00:00:00-03:00',
|
||||
end: '2027-02-01T00:00:00-03:00',
|
||||
anchorDate: '2027-01-01',
|
||||
},
|
||||
'prev',
|
||||
)
|
||||
// Dezembro/2026: 1º é terça → segunda antecedente = 30 Novembro.
|
||||
expect(prev).toEqual({
|
||||
start: '2026-11-30T03:00:00.000Z',
|
||||
end: '2027-01-11T03:00:00.000Z',
|
||||
anchorDate: '2026-12-01',
|
||||
})
|
||||
expect(activityAgendaPeriodLabel('month', prev!.start, prev!.end, prev!.anchorDate)).toBe(
|
||||
'Dezembro',
|
||||
)
|
||||
})
|
||||
|
||||
it('lida com ano bissexto na virada do mês (Fevereiro 2028)', () => {
|
||||
const next = activityAgendaAdjacentPeriod(
|
||||
'month',
|
||||
{
|
||||
start: '2028-01-31T00:00:00-03:00',
|
||||
end: '2028-03-01T00:00:00-03:00',
|
||||
anchorDate: '2028-02-01',
|
||||
},
|
||||
'next',
|
||||
)
|
||||
// Março/2028: 1º é quarta → segunda antecedente = 28 Fevereiro.
|
||||
expect(next).toEqual({
|
||||
start: '2028-02-28T03:00:00.000Z',
|
||||
end: '2028-04-10T03:00:00.000Z',
|
||||
anchorDate: '2028-03-01',
|
||||
})
|
||||
})
|
||||
|
||||
it('desloca a lista em um mês inteiro (range do mês adjacente)', () => {
|
||||
expect(
|
||||
activityAgendaAdjacentPeriod(
|
||||
'list',
|
||||
{
|
||||
start: '2026-08-01T00:00:00-03:00',
|
||||
end: '2026-09-01T00:00:00-03:00',
|
||||
anchorDate: '2026-08-01',
|
||||
},
|
||||
'next',
|
||||
),
|
||||
).toEqual({
|
||||
start: '2026-09-01T03:00:00.000Z',
|
||||
end: '2026-10-01T03:00:00.000Z',
|
||||
anchorDate: '2026-09-01',
|
||||
})
|
||||
})
|
||||
|
||||
it('retorna null quando o anchor do mês não é o 1º ou o intervalo é ilegível', () => {
|
||||
expect(
|
||||
activityAgendaAdjacentPeriod(
|
||||
'month',
|
||||
{
|
||||
start: '2026-08-01T00:00:00-03:00',
|
||||
end: '2026-09-01T00:00:00-03:00',
|
||||
anchorDate: '2026-08-15',
|
||||
},
|
||||
'next',
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
activityAgendaAdjacentPeriod(
|
||||
'day',
|
||||
{
|
||||
start: 'não-é-data',
|
||||
end: '2026-08-10T00:00:00-03:00',
|
||||
anchorDate: '2026-08-09',
|
||||
},
|
||||
'next',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('label do preview == título pós-commit (paridade dia/semana/mês/lista)', () => {
|
||||
const day = activityAgendaAdjacentPeriod(
|
||||
'day',
|
||||
{
|
||||
start: '2026-08-09T00:00:00-03:00',
|
||||
end: '2026-08-10T00:00:00-03:00',
|
||||
anchorDate: '2026-08-09',
|
||||
},
|
||||
'next',
|
||||
)
|
||||
expect(activityAgendaPeriodLabel('day', day!.start, day!.end, day!.anchorDate)).toBe(
|
||||
'10 Agosto',
|
||||
)
|
||||
|
||||
const week = activityAgendaAdjacentPeriod(
|
||||
'week',
|
||||
{
|
||||
start: '2026-07-27T00:00:00-03:00',
|
||||
end: '2026-08-03T00:00:00-03:00',
|
||||
anchorDate: '2026-07-27',
|
||||
},
|
||||
'next',
|
||||
)
|
||||
expect(activityAgendaPeriodLabel('week', week!.start, week!.end, week!.anchorDate)).toBe(
|
||||
'3–9 Agosto',
|
||||
)
|
||||
|
||||
const month = activityAgendaAdjacentPeriod(
|
||||
'month',
|
||||
{
|
||||
start: '2026-07-26T00:00:00-03:00',
|
||||
end: '2026-09-01T00:00:00-03:00',
|
||||
anchorDate: '2026-08-01',
|
||||
},
|
||||
'next',
|
||||
)
|
||||
expect(activityAgendaPeriodLabel('month', month!.start, month!.end, month!.anchorDate)).toBe(
|
||||
'Setembro',
|
||||
)
|
||||
|
||||
const list = activityAgendaAdjacentPeriod(
|
||||
'list',
|
||||
{
|
||||
start: '2026-08-01T00:00:00-03:00',
|
||||
end: '2026-09-01T00:00:00-03:00',
|
||||
anchorDate: '2026-08-01',
|
||||
},
|
||||
'next',
|
||||
)
|
||||
expect(activityAgendaPeriodLabel('list', list!.start, list!.end, list!.anchorDate)).toBe(
|
||||
'Agenda',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activityAgendaViewFromFcId', () => {
|
||||
it('espelha activityAgendaViewFcId para as quatro vistas', () => {
|
||||
expect(activityAgendaViewFromFcId('timeGridWeek')).toBe('week')
|
||||
|
|
|
|||
|
|
@ -27,11 +27,15 @@ const Harness = ({
|
|||
enabled = true,
|
||||
blockRef,
|
||||
onSwipe,
|
||||
onSwipePreviewStart,
|
||||
onSwipePreviewEnd,
|
||||
onReady,
|
||||
}: {
|
||||
enabled?: boolean
|
||||
blockRef?: MutableRefObject<boolean>
|
||||
onSwipe: (direction: 'next' | 'prev') => void
|
||||
onSwipePreviewStart?: (direction: 'next' | 'prev') => void
|
||||
onSwipePreviewEnd?: () => void
|
||||
onReady?: (suppressDateClickRef: MutableRefObject<boolean>) => void
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -41,16 +45,42 @@ const Harness = ({
|
|||
enabled,
|
||||
blockRef: blockRef ?? internalBlockRef,
|
||||
onSwipe,
|
||||
onSwipePreviewStart,
|
||||
onSwipePreviewEnd,
|
||||
})
|
||||
if (onReady) onReady(suppressDateClickRef)
|
||||
return createElement('div', { ref: containerRef, 'data-testid': 'agenda' })
|
||||
}
|
||||
const agendaOf = (container: HTMLElement): HTMLElement =>
|
||||
container.firstElementChild as HTMLElement
|
||||
|
||||
const swipe = (container: HTMLElement, fromX: number, toX: number, y = 100) => {
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
/** Dispatches a cancellable touchmove and returns the event for assertion. */
|
||||
const touchMoveAt = (agenda: HTMLElement, x: number, y: number): Event => {
|
||||
const event = new Event('touchmove', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'touches', {
|
||||
value: [{ clientX: x, clientY: y }],
|
||||
})
|
||||
agenda.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
const claim = (agenda: HTMLElement, fromX: number, toX: number, y = 100) => {
|
||||
// C110 — the claim lives in the non-passive touchmove: pointerdown, then a
|
||||
// touchmove past the 12px dead zone (dominance horizontal), then the
|
||||
// pointermove that keeps the transform fresh.
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: fromX, clientY: y }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: toX, clientY: y }] })
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: toX, clientY: y }))
|
||||
fireEvent(window, pointerEvent('pointerup', { clientX: toX, clientY: y }))
|
||||
}
|
||||
|
||||
const release = (_agenda: HTMLElement, x: number, y = 100) => {
|
||||
fireEvent(window, pointerEvent('pointerup', { clientX: x, clientY: y }))
|
||||
}
|
||||
|
||||
const dragAndRelease = (container: HTMLElement, fromX: number, toX: number, y = 100) => {
|
||||
const agenda = agendaOf(container)
|
||||
claim(agenda, fromX, toX, y)
|
||||
release(agenda, toX, y)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -59,28 +89,122 @@ afterEach(() => {
|
|||
})
|
||||
|
||||
describe('useAgendaSwipeNavigation', () => {
|
||||
it('navega para o período seguinte com um arrasto horizontal à esquerda', () => {
|
||||
it('commita o período seguinte ao soltar acima do limiar de 48px', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
swipe(container as HTMLElement, 300, 100)
|
||||
const onSwipePreviewStart = vi.fn()
|
||||
const onSwipePreviewEnd = vi.fn()
|
||||
const { container } = render(
|
||||
createElement(Harness, { onSwipe, onSwipePreviewStart, onSwipePreviewEnd }),
|
||||
)
|
||||
dragAndRelease(container as HTMLElement, 300, 100)
|
||||
expect(onSwipe).toHaveBeenCalledTimes(1)
|
||||
expect(onSwipe).toHaveBeenCalledWith('next')
|
||||
expect(onSwipePreviewStart).toHaveBeenCalledWith('next')
|
||||
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('navega para o período anterior com um arrasto horizontal à direita', () => {
|
||||
it('commita o período anterior ao soltar à direita', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
swipe(container as HTMLElement, 100, 300)
|
||||
dragAndRelease(container as HTMLElement, 100, 300)
|
||||
expect(onSwipe).toHaveBeenCalledWith('prev')
|
||||
})
|
||||
|
||||
it('não navega abaixo do limiar de 48px', () => {
|
||||
it('não navega abaixo do limiar: snap-back sem onSwipe', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
swipe(container as HTMLElement, 300, 260)
|
||||
const onSwipePreviewEnd = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewEnd }))
|
||||
dragAndRelease(container as HTMLElement, 300, 260)
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('não abre preview nem transforma o grid abaixo do claim de 12px', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const onSwipePreviewStart = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewStart }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 291, clientY: 100 }] })
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 291, clientY: 100 }))
|
||||
release(agenda, 291)
|
||||
expect(onSwipePreviewStart).not.toHaveBeenCalled()
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(agenda.style.transform).toBe('')
|
||||
})
|
||||
|
||||
it('abre o preview e transforma o grid no claim; o grid segue o dedo', () => {
|
||||
const onSwipePreviewStart = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe: vi.fn(), onSwipePreviewStart }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 285, clientY: 100 }] })
|
||||
expect(onSwipePreviewStart).toHaveBeenCalledWith('next')
|
||||
expect(agenda.classList.contains('activity-agenda-swipe-dragging')).toBe(true)
|
||||
expect(agenda.style.transform).toBe('translateX(-15px)')
|
||||
// o dedo continua: o transform acompanha (200px de deslocamento total)
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
|
||||
expect(agenda.style.transform).toBe('translateX(-200px)')
|
||||
release(agenda, 100)
|
||||
})
|
||||
|
||||
it('preventDefault em todo touchmove claimado (segura o gesto do browser)', () => {
|
||||
const { container } = render(createElement(Harness, { onSwipe: vi.fn() }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
// o claim preventDefault o primeiro move horizontal
|
||||
const claimEvent = touchMoveAt(agenda, 285, 100)
|
||||
expect(claimEvent.defaultPrevented).toBe(true)
|
||||
// e CADA move seguinte também — o pan controller reavalia por move e um
|
||||
// move não-prevented dispara pointercancel real, matando o gesto vivo
|
||||
const nextMove = touchMoveAt(agenda, 200, 100)
|
||||
expect(nextMove.defaultPrevented).toBe(true)
|
||||
const lastMove = touchMoveAt(agenda, 150, 100)
|
||||
expect(lastMove.defaultPrevented).toBe(true)
|
||||
release(agenda, 150)
|
||||
})
|
||||
|
||||
it('não preventDefault em touchmove vertical (o scroll nativo continua)', () => {
|
||||
const { container } = render(createElement(Harness, { onSwipe: vi.fn() }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
const verticalMove = touchMoveAt(agenda, 300, 180)
|
||||
expect(verticalMove.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('trava a direção no claim: reverter não vira o preview nem cruza a origem', () => {
|
||||
const onSwipePreviewStart = vi.fn()
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewStart }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
|
||||
expect(onSwipePreviewStart).toHaveBeenCalledWith('next')
|
||||
// reversão para +100px: clamp na direção 'next' — o grid nunca cruza a origem
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 400, clientY: 100 }))
|
||||
expect(agenda.style.transform).toBe('')
|
||||
// soltar em +100 não commita (deslocamento final é 0 no domínio travado)
|
||||
release(agenda, 400)
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('não navega num arrasto diagonal (dominância vertical)', () => {
|
||||
it('abandona e volta quando um gesto claimado vira vertical (dominância)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const onSwipePreviewEnd = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewEnd }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
|
||||
expect(agenda.style.transform).toBe('translateX(-20px)')
|
||||
// vertical 3:1 sobre o horizontal — o scroll tomou o gesto
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 280, clientY: 400 }))
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
|
||||
expect(agenda.style.transform).toBe('')
|
||||
expect(agenda.classList.contains('activity-agenda-swipe-exit')).toBe(true)
|
||||
})
|
||||
|
||||
it('não navega num arrasto diagonal antes do claim (dominância vertical)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
fireEvent(
|
||||
|
|
@ -91,6 +215,18 @@ describe('useAgendaSwipeNavigation', () => {
|
|||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('não commita no soltar se o deslocamento final virou vertical', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 250, clientY: 100 }] })
|
||||
// deslocamento final: dx=-50 (acima do limiar) mas dy=-80 (dominância vertical)
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 250, clientY: 180 }))
|
||||
release(agenda, 250, 180)
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignora mouse (a toolbar do desktop continua mandando)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
|
|
@ -107,26 +243,47 @@ describe('useAgendaSwipeNavigation', () => {
|
|||
|
||||
it('não inicia gesto com enabled=false', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { enabled: false, onSwipe }))
|
||||
swipe(container as HTMLElement, 300, 100)
|
||||
const onSwipePreviewStart = vi.fn()
|
||||
const { container } = render(
|
||||
createElement(Harness, { enabled: false, onSwipe, onSwipePreviewStart }),
|
||||
)
|
||||
dragAndRelease(container as HTMLElement, 300, 100)
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(onSwipePreviewStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('abandona o gesto quando o FullCalendar começa um drag de evento (blockRef)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const onSwipePreviewEnd = vi.fn()
|
||||
const blockRef = { current: false }
|
||||
const { container } = render(createElement(Harness, { blockRef, onSwipe }))
|
||||
fireEvent(
|
||||
container.firstElementChild as HTMLElement,
|
||||
pointerEvent('pointerdown', { clientX: 300, clientY: 100 }),
|
||||
)
|
||||
const { container } = render(createElement(Harness, { blockRef, onSwipe, onSwipePreviewEnd }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
// a flag vira true a meio do gesto (long-press do FC venceu)
|
||||
blockRef.current = true
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(onSwipePreviewEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marca suppressDateClickRef no consumo (o swipe não abre o inline create)', () => {
|
||||
it('fecha o preview e volta no pointercancel depois do claim', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const onSwipePreviewEnd = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewEnd }))
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 250, clientY: 100 }] })
|
||||
// o browser tomou o gesto (scroll vertical): pointercancel
|
||||
fireEvent(window, pointerEvent('pointercancel', { clientX: 250, clientY: 180 }))
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
|
||||
expect(agenda.style.transform).toBe('')
|
||||
// o swipe seguinte funciona normalmente
|
||||
dragAndRelease(container as HTMLElement, 300, 100)
|
||||
expect(onSwipe).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('marca suppressDateClickRef no claim (o swipe não abre o inline create)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
let suppressDateClickRef: MutableRefObject<boolean> | undefined
|
||||
const { container } = render(
|
||||
|
|
@ -134,20 +291,9 @@ describe('useAgendaSwipeNavigation', () => {
|
|||
)
|
||||
expect(suppressDateClickRef).toBeDefined()
|
||||
expect(suppressDateClickRef!.current).toBe(false)
|
||||
swipe(container as HTMLElement, 300, 100)
|
||||
expect(suppressDateClickRef!.current).toBe(true)
|
||||
})
|
||||
|
||||
it('marca suppressDateClickRef já no claim de 12px (um flick sub-limiar não abre o inline create)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
let suppressDateClickRef: MutableRefObject<boolean> | undefined
|
||||
const { container } = render(
|
||||
createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }),
|
||||
)
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
// flick de 20px: cruza o claim (12px) mas não o limiar de navegação
|
||||
// (48px) — o claim vive no touchmove não-passivo.
|
||||
// flick de 20px: cruza o claim (12px) mas não o limiar de navegação (48px)
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(suppressDateClickRef!.current).toBe(true)
|
||||
|
|
@ -159,9 +305,9 @@ describe('useAgendaSwipeNavigation', () => {
|
|||
const { container } = render(
|
||||
createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }),
|
||||
)
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
|
||||
expect(suppressDateClickRef!.current).toBe(true)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 200, clientY: 100 }))
|
||||
expect(suppressDateClickRef!.current).toBe(false)
|
||||
|
|
@ -170,28 +316,28 @@ describe('useAgendaSwipeNavigation', () => {
|
|||
it('aceita pen além de touch', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(
|
||||
agenda,
|
||||
pointerEvent('pointerdown', { pointerType: 'pen', clientX: 300, clientY: 100 }),
|
||||
)
|
||||
// pen gera touch events no Chrome — o claim vive no touchmove como no touch
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
|
||||
fireEvent(
|
||||
window,
|
||||
pointerEvent('pointermove', { pointerType: 'pen', clientX: 100, clientY: 100 }),
|
||||
)
|
||||
release(agenda, 100)
|
||||
expect(onSwipe).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('reseta o gesto no pointercancel e aceita o próximo swipe', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
// o browser tomou o gesto (scroll vertical): pointercancel
|
||||
const agenda = agendaOf(container as HTMLElement)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent(window, pointerEvent('pointercancel', { clientX: 300, clientY: 180 }))
|
||||
// o swipe seguinte funciona normalmente
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
|
||||
dragAndRelease(container as HTMLElement, 300, 100)
|
||||
expect(onSwipe).toHaveBeenCalledWith('next')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue