teqo/src/components/campaign/activity/useAgendaKeyboardNavigation.ts
Francisco Solla f3c41d3382 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
2026-08-10 00:53:19 -03:00

91 lines
3.3 KiB
TypeScript

'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 }
}