12 KiB
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 propmonthLabel/onPrevMonth/onNextMonthdel 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 capturaronPointerDowndel 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 tieneposition: 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 serposition: absolutehijos directos de.scroll, igual que ya hace.scrollHint(style.module.scss:232-240, right-edge fade decorativo). Un elementoposition: absolutecuyo containing block es el propio elemento conoverflow-x: autono 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.scrollHintpara quedar fijo en el borde derecho. - Columnas sticky a esquivar:
.agentCell/.agentHead(ancho 200px, stickyleft:0) y, sishowSummaryestá activo (defaulttrue),.summaryCell/.summaryHead(ancho 88px adicional, stickyleft:200px). El botón izquierdo no debe ir enleft:0(taparía el nombre del agente) — debe ir en el borde donde terminan las columnas congeladas:left: 200px(sin summary) oleft: 288px(con summary). El botón derecho va enright: 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 sumabox-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 encomponents/dashboard/AttendanceGrid.tsx:229-247, para el menú de status). No hay ningún patrónArrowLeft/ArrowRightexistente — 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:autosin 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):
useRef<HTMLDivElement>sobre el<div className={styles.scroll}>.useStatecon{ canLeft: boolean, canRight: boolean }. UnuseEffectcalcula el estado inicial al montar/cuando cambianagents/days(el ancho de la tabla puede cambiar de mes a mes) comparandoscrollWidthvsclientWidthdel 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.- Listener de
scrollsobre el propio contenedor (recalculacanLeft/canRighten cada evento) +ResizeObserversobre el contenedor (recalcula si el usuario resizea la ventana o cambia el layout del dashboard). Ambos se limpian en el cleanup deluseEffect. - Dos
<button>con la clase.navBtnexistente (mismo trato visual, conbox-shadow/fondo agregado para el overlay),type="button",aria-label="Scroll attendance grid left"/"...right",disabledcuando!canLeft/!canRight,data-scroll-left/data-scroll-rightpara hooks de test (mismo patrón que eldata-scroll/data-scroll-hintya existentes). onClick:scrollRef.current?.scrollBy({ left: ±Math.round(scrollRef.current.clientWidth * 0.8), behavior }), conbehavior='auto'siwindow.matchMedia('(prefers-reduced-motion: reduce)').matches, si no'smooth'.- El
.scrollHintdecorativo (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— agregaruseRef/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 (mockearscrollWidth/clientWidthvíaObject.definePropertyen el elemento, patrón estándar de RTL para jsdom), presentes y conaria-labelcorrectos cuando sí hay overflow, click llama ascrollBy(mockElement.prototype.scrollBy) con el signo correcto,disabledreflejascrollLeften los extremos tras disparar el eventoscroll.packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.cy.tsx— extender el bloquedescribe('AttendanceGrid (component) — scroll & sticky', ...)(líneas 66-176) ya existente: click en el botón derecho aumentascrollLeftreal (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 conagents/dayssuficientes 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):
- 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 desdedev— mismo target que GFIBER-663). - 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. - Nunca mergea — al terminar, abre PR hacia
devvía la skillaleleba-pr(output en inglés, sinCo-Authored-Byni trailers de atribución, por convención del repo). - 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.
- 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
npm run test(Vitest, app) — debe seguir en verde, sin tocar comportamiento del wrapper de la app.- Jest del design-system (
npm run test --workspace=@gfiber/design-systemo el script equivalente) — nuevos tests de botones en verde. - Cypress component tests del DS — nuevas aserciones de scroll real (
scrollLeft,disabled, posición fija) en verde. - 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.
- 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). npm run lint+tsclimpios.