Files
qwen3-6-lora/data/raw/sanitized/plans/puedes-leer-el-ticket-indexed-duckling.md

12 KiB
Raw Permalink Blame History

GFIBER-671 — Botones de scroll izquierda/derecha en el Attendance Grid

Context

Ticket: GFIBER-671 (High, To Do, asignado a Alejandro Lembke) — "Add buttons to move the attendance calendar to the right or to the left (like scrolling)". Surgió en el daily del 2026-07-02: "The scroll bar doesn't get displayed for some users, so we need something else to enable them to navigate the attendance calendar."

El mismo día se cerró GFIBER-663 (PR #89 → dev), que atacó el síntoma raíz por CSS: en Windows con "Automatically hide scroll bars" (activado por defecto en muchas configuraciones), el scrollbar horizontal del AttendanceGrid nunca se renderizaba como barra persistente/arrastrable para usuarios de mouse en desktop. El fix fue scrollbar-width/scrollbar-color + ::-webkit-scrollbar* sobre el contenedor .scroll, forzando un scrollbar clásico siempre visible.

GFIBER-671 es el fast-follow: incluso con un scrollbar visible, seguir dependiendo exclusivamente de arrastrar una barra de 10px es una interacción frágil (precisión de mouse, descubribilidad, accesibilidad motora). El pedido es un control explícito e independiente del navegador — flechas que desplazan la grilla — como refuerzo, no como reemplazo del scrollbar ya corregido.

Resultado buscado: dos botones flecha (izquierda/derecha) superpuestos sobre el borde del área scrolleable del AttendanceGrid, que aparecen solo cuando hay contenido para desplazar, se deshabilitan en los extremos, y desplazan la grilla con un click — sin tocar persistencia, Server Actions, ni el modelo de datos (es una mejora puramente de interacción de UI).

Investigación previa

Confirmado leyendo el código real (no solo la documentación de Docmost):

  • Componente objetivo: packages/design-system/src/components/AttendanceGrid/index.tsx — component plano "use client", sin refs ni estado hoy (100% driven by props). El wrapper de la app, components/dashboard/AttendanceGrid.tsx, no necesita cambios: no usa el prop monthLabel/onPrevMonth/onNextMonth del DS (tiene su propio header de navegación de mes fuera de este componente) y solo envuelve el render del DS en un <div> para capturar onPointerDown del status-picker — no posee ref al contenedor de scroll. Todo el trabajo va dentro del DS, consistente con la convención DS-first del proyecto (ningún control nuevo va inline en la app).
  • Contenedor de scroll: <div className={styles.scroll} data-scroll> (index.tsx:125) — ya tiene position: relative (style.module.scss:58), heredado del fix de GFIBER-663. Esto es clave: no hace falta reestructurar CSS para tener un contexto de posicionamiento — los botones pueden ser position: absolute hijos directos de .scroll, igual que ya hace .scrollHint (style.module.scss:232-240, right-edge fade decorativo). Un elemento position: absolute cuyo containing block es el propio elemento con overflow-x: auto no se desplaza con el scroll (se posiciona respecto al padding-box estático del contenedor, no respecto al contenido que se desplaza) — es el mismo mecanismo que ya usa .scrollHint para quedar fijo en el borde derecho.
  • Columnas sticky a esquivar: .agentCell/.agentHead (ancho 200px, sticky left:0) y, si showSummary está activo (default true), .summaryCell/.summaryHead (ancho 88px adicional, sticky left:200px). El botón izquierdo no debe ir en left:0 (taparía el nombre del agente) — debe ir en el borde donde terminan las columnas congeladas: left: 200px (sin summary) o left: 288px (con summary). El botón derecho va en right: 0, en el mismo slot que hoy ocupa .scrollHint.
  • Estilo a reutilizar (decisión confirmada con el usuario): .navBtn (style.module.scss:30-47) — círculo 28px, / unicode, hover --fiber-gray-100, focus-visible --fiber-blue. Hoy este estilo solo lo usa el header de mes opcional del propio componente (index.tsx:107-123), que la app no consume — es decir, existe y está validado visualmente pero no tiene ningún consumidor real todavía. Reutilizarlo mantiene consistencia visual con la identidad ya definida en Penpot para este componente y cumple el mínimo de WCAG 2.2 SC 2.5.8 (24×24px). Se le suma box-shadow + fondo sólido (--fiber-surface) para que se lea flotando sobre celdas de cualquier color (weekend, today, status tonos).
  • Sin precedente de teclado Left/Right: el único roving-focus del repo usa ArrowUp/ArrowDown (Picker en components/dashboard/AttendanceGrid.tsx:229-247, para el menú de status). No hay ningún patrón ArrowLeft/ArrowRight existente — no es necesario inventarlo para este ticket (los botones son suficientes; el foco natural por Tab ya los hace accesibles por teclado).
  • DataTable/UserTable comparten el mismo overflow-x:auto sin botones — confirmado, fuera de alcance de este ticket (ya está anotado como deuda fast-follow desde GFIBER-663).

Approach

Todo el cambio vive en packages/design-system/src/components/AttendanceGrid/. Component sigue siendo controlado por props para todo lo existente; se le agrega estado interno puramente de presentación (no se expone a la app):

  1. useRef<HTMLDivElement> sobre el <div className={styles.scroll}>.
  2. useState con { canLeft: boolean, canRight: boolean }. Un useEffect calcula el estado inicial al montar/cuando cambian agents/days (el ancho de la tabla puede cambiar de mes a mes) comparando scrollWidth vs clientWidth del ref; si no hay overflow (scrollWidth <= clientWidth), ambos botones se ocultan por completo (no solo deshabilitan) — no tiene sentido mostrar controles de scroll cuando no hay nada que desplazar.
  3. Listener de scroll sobre el propio contenedor (recalcula canLeft/canRight en cada evento) + ResizeObserver sobre el contenedor (recalcula si el usuario resizea la ventana o cambia el layout del dashboard). Ambos se limpian en el cleanup del useEffect.
  4. Dos <button> con la clase .navBtn existente (mismo trato visual, con box-shadow/fondo agregado para el overlay), type="button", aria-label="Scroll attendance grid left" / "...right", disabled cuando !canLeft / !canRight, data-scroll-left / data-scroll-right para hooks de test (mismo patrón que el data-scroll/data-scroll-hint ya existentes).
  5. onClick: scrollRef.current?.scrollBy({ left: ±Math.round(scrollRef.current.clientWidth * 0.8), behavior }), con behavior = 'auto' si window.matchMedia('(prefers-reduced-motion: reduce)').matches, si no 'smooth'.
  6. El .scrollHint decorativo (fade derecho) se elimina — el botón funcional en el mismo slot lo vuelve redundante y visualmente competiría con él en el borde derecho. Si en la revisión visual se prefiere mantenerlo detrás del botón, es un ajuste de una línea, pero la recomendación es no duplicar la señal.

Archivos a modificar

  • packages/design-system/src/components/AttendanceGrid/index.tsx — agregar useRef/useState/useEffect + los dos botones dentro de .scroll, después del <table> (reemplazando el <span className={styles.scrollHint}> actual).
  • packages/design-system/src/components/AttendanceGrid/style.module.scss — nuevas clases .scrollBtn (extiende .navBtn: position: absolute, top: 50%, transform: translateY(-50%), z-index: 4, background-color: var(--fiber-surface), border: 1px solid var(--fiber-gray-200), box-shadow: var(--shadow-sm)), .scrollBtnLeft { left: 200px } / .scrollBtnLeftWithSummary { left: 288px } (o resolver el offset inline con una custom property, a decidir en implementación) y .scrollBtnRight { right: 8px }. Eliminar .scrollHint.
  • packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.tsx (Jest/RTL) — nuevos tests: botones ausentes cuando no hay overflow (mockear scrollWidth/clientWidth vía Object.defineProperty en el elemento, patrón estándar de RTL para jsdom), presentes y con aria-label correctos cuando sí hay overflow, click llama a scrollBy (mock Element.prototype.scrollBy) con el signo correcto, disabled refleja scrollLeft en los extremos tras disparar el evento scroll.
  • packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.cy.tsx — extender el bloque describe('AttendanceGrid (component) — scroll & sticky', ...) (líneas 66-176) ya existente: click en el botón derecho aumenta scrollLeft real (layout de navegador real, no mock), click izquierdo lo disminuye, botón derecho se oculta/deshabilita al llegar al final, botón izquierdo al llegar al inicio, los botones permanecen visualmente anclados al hacer scroll (no se desplazan con el contenido), y no tapan el nombre del agente (posición izquierda después de las columnas sticky).
  • packages/design-system/src/components/AttendanceGrid/AttendanceGrid.stories.tsx — story adicional con agents/days suficientes para forzar overflow en un viewport angosto, para revisión visual en Storybook antes de mergear.

No se toca components/dashboard/AttendanceGrid.tsx (wrapper de la app), ni Server Actions, ni el esquema de Supabase — cambio 100% contenido en el design system, sin persistencia involucrada.

Ejecución

Este trabajo se despacha con la skill global background-orchestrator (no se implementa en esta sesión interactiva). Siguiendo el mismo patrón ya usado para el ticket hermano GFIBER-663 (agente-gfiber-663-attendance-scrollbar, registrado en la página Docmost Agentes_Activos):

  1. Agente autónomo en tmux, nombre sugerido agente-gfiber-671-attendance-scroll-buttons, corriendo en su propio git worktree bajo .worktrees/ sobre una rama dedicada (feat/gfiber-671-attendance-scroll-buttons, branch desde dev — mismo target que GFIBER-663).
  2. TDD estricto (rojo→verde): el agente escribe primero los tests de Jest/RTL y Cypress descritos en la sección de Verificación antes de tocar index.tsx/style.module.scss.
  3. Nunca mergea — al terminar, abre PR hacia dev vía la skill aleleba-pr (output en inglés, sin Co-Authored-By ni trailers de atribución, por convención del repo).
  4. Se documenta en Docmost: entrada nueva en Agentes_Activos (historial) y actualización de la subpágina Grid_de_Asistencia_(Attendance) con el resultado, igual que se hizo para GFIBER-663.
  5. El orquestador (esta sesión) monitorea el progreso del agente, responde preguntas si el agente se bloquea, y valida/archiva el resultado al finalizar (/aprobar agente-gfiber-671-attendance-scroll-buttons) — sin mergear directamente.

Este plan (el archivo completo hasta aquí) es el brief que se le entrega al agente en background como contexto de arranque.

Verificación

  1. npm run test (Vitest, app) — debe seguir en verde, sin tocar comportamiento del wrapper de la app.
  2. Jest del design-system (npm run test --workspace=@gfiber/design-system o el script equivalente) — nuevos tests de botones en verde.
  3. Cypress component tests del DS — nuevas aserciones de scroll real (scrollLeft, disabled, posición fija) en verde.
  4. Revisión visual en Storybook: confirmar que el botón izquierdo no se superpone al nombre del agente ni al header sticky, y que el botón derecho no queda apretado contra el borde de la tarjeta.
  5. Prueba manual en DEV con un roster que exceda el ancho del viewport (mes completo, varios agentes): click en ambas flechas desplaza la grilla, se deshabilitan/ocultan en los extremos, funcionan con teclado (Tab + Enter/Space) y son anunciados correctamente por lector de pantalla (aria-label).
  6. npm run lint + tsc limpios.