diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index d854082..9cd0dd1 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -590,3 +590,12 @@ plantillas conserva sus switches y su significado. Muestra el asterisco junto al nombre y conserva ese texto para lectores de pantalla. Los campos optativos no muestran indicación de opcionalidad; tipo, unidad y avisos se mantienen como metadatos de consulta. + + +## Pestañas horizontales desplazables + +`ScrollableTabsList` de `@repo/rdx-ui/components/scrollable-tabs-list` se compone dentro de `Tabs` de shadcn/ui. Recibe `items` (identidad `value` y etiqueta `label`), `value`, una etiqueta accesible `label` y `locale` para los controles es/en. El consumidor mantiene la selección, los paneles y sus efectos sobre URL, filtros o consultas. + +Los listados de activos y recursos lo utilizan para sus familias; el picker de activos lo hereda mediante `AssetList`. La barra ocupa únicamente el ancho disponible, mantiene una sola fila y no comprime los nombres. Permite desplazamiento horizontal nativo con tacto o trackpad y muestra botones laterales solo cuando hay desbordamiento. Los botones se deshabilitan al llegar a cada extremo. La barra de desplazamiento nativa permanece oculta, evitando controles verticales accidentales; los botones conservan nombre accesible y tooltip. + +Conserva la semántica y el teclado de Base UI. Al cambiar de familia, enfocar una pestaña o redimensionar el contenedor, revela la pestaña correspondiente moviendo solo el eje horizontal. Un `ResizeObserver` actualiza los extremos al abrir o cerrar paneles, redimensionar la ventana o cambiar las etiquetas; se retiran el observer y los listeners al desmontar. La raíz `Tabs` y los paneles consumidores mantienen `min-w-0` para permitir que la barra se reduzca dentro de flex y grid. diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md index 0ac2e0f..a5f0cec 100644 --- a/docs/patron-de-listados-de-entidades.md +++ b/docs/patron-de-listados-de-entidades.md @@ -37,6 +37,8 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c - Cancelar un modal conserva búsqueda, filtros, página y selección de la lista. Las actualizaciones continúan invalidando las consultas actuales. - Las consultas que alimentan un listado usan `useListQuery`: se vuelven a consultar cada 60 segundos mientras la pestaña está visible y también al recuperar foco o conexión. No se consulta en segundo plano ni se aplica este intervalo a formularios, sesión u otras consultas que no presenten un listado. +Cuando un listado filtra familias mediante pestañas, utilizar `ScrollableTabsList` de `@repo/rdx-ui` dentro de `Tabs`. La barra mantiene una sola fila limitada al ancho del listado, con desplazamiento horizontal, controles laterales solo cuando sean necesarios y la pestaña activa visible. Mantener `min-w-0` en la raíz y el contenido para que ni las etiquetas ni la tabla ensanchen la página. La selección conserva las reglas de URL, filtros y paginación del listado. Véase [pestañas horizontales desplazables](componentes-de-interfaz.md#pestañas-horizontales-desplazables). + ## Celdas sin valor En cualquier listado, tabla, sublistado o PickerList, una celda sin valor muestra exclusivamente un guion largo (`—`, U+2014), cualquiera que sea la causa de la ausencia. No mostrar «Sin indicar», «Sin valor», «Sin especificar», «Sin asignación», «Sin fecha», «Sin logotipo» ni equivalentes en otros idiomas. El signo es común a es/en y no requiere traducción. diff --git a/modules/assets/src/web/assets/shared/ui/asset-list.tsx b/modules/assets/src/web/assets/shared/ui/asset-list.tsx index 0b704db..ab4db28 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-list.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-list.tsx @@ -1,6 +1,7 @@ import type { EntityColumn } from "@repo/rdx-ui/components/entity-list"; import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs"; +import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list"; +import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -72,22 +73,21 @@ export function AssetList({ return ( -
- - {t("allAssets")} - {[...templates].map(([id, name]) => ( - - {name} - - ))} - -
+ ({ value, label })), + ]} + label={t("filterByTemplate")} + locale={i18n.language} + value={templateId ?? "all"} + /> diff --git a/modules/resources/src/web/resources/list/ui/resources-page.tsx b/modules/resources/src/web/resources/list/ui/resources-page.tsx index 4064d9b..f10fd50 100644 --- a/modules/resources/src/web/resources/list/ui/resources-page.tsx +++ b/modules/resources/src/web/resources/list/ui/resources-page.tsx @@ -6,9 +6,10 @@ import { EntityStatus, EntityTable, } from "@repo/rdx-ui/components/entity-list"; +import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list"; import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary"; import { Button } from "@repo/shadcn-ui/components/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs"; +import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs"; import { useTranslation } from "react-i18next"; import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon"; @@ -93,18 +94,17 @@ export function ResourcesPage(props: ResourcesPageProps) { {t("retry")} )} - -
- - {t("allResources")} - {templates.map((template) => ( - - {template.values.name} - - ))} - -
- + + ({ value: template.id, label: template.values.name })), + ]} + label={t("filterByTemplate")} + locale={i18n.language} + value={tabValue} + /> + + {scroll.overflow && ( + + )} +
+ + {items.map((item) => ( + + {item.label} + + ))} + +
+ {scroll.overflow && ( + + )} + + ); +} diff --git a/packages/rdx-ui/src/hooks/use-scrollable-tabs.ts b/packages/rdx-ui/src/hooks/use-scrollable-tabs.ts new file mode 100644 index 0000000..7486607 --- /dev/null +++ b/packages/rdx-ui/src/hooks/use-scrollable-tabs.ts @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from "react"; + +/** Sincroniza la navegación con la geometría real, también al abrir paneles o cambiar etiquetas. */ +export function useScrollableTabs(value: string) { + const containerRef = useRef(null); + const viewportRef = useRef(null); + const listRef = useRef(null); + const [scroll, setScroll] = useState({ overflow: false, backward: false, forward: false }); + + useEffect(() => { + const container = containerRef.current; + const viewport = viewportRef.current; + const list = listRef.current; + + if (!(container && viewport && list)) { + return; + } + + const measure = () => { + const next = { + overflow: list.scrollWidth > container.clientWidth + 1, + backward: viewport.scrollLeft > 1, + forward: viewport.scrollWidth - viewport.clientWidth - viewport.scrollLeft > 1, + }; + + setScroll((previous) => + previous.overflow === next.overflow && + previous.backward === next.backward && + previous.forward === next.forward + ? previous + : next + ); + }; + + const revealSelected = () => { + const selected = Array.from(list.querySelectorAll('[role="tab"]')).find( + (tab) => tab.dataset.value === value + ); + + if (selected) { + revealTab(viewport, selected); + } + measure(); + }; + + const revealFocused = (event: FocusEvent) => { + if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab") { + revealTab(viewport, event.target); + } + }; + + const observer = new ResizeObserver(revealSelected); + + observer.observe(container); + observer.observe(viewport); + observer.observe(list); + viewport.addEventListener("scroll", measure, { passive: true }); + list.addEventListener("focusin", revealFocused); + revealSelected(); + + return () => { + observer.disconnect(); + viewport.removeEventListener("scroll", measure); + list.removeEventListener("focusin", revealFocused); + }; + }, [value]); + + const scrollByPage = (direction: number) => { + const viewport = viewportRef.current; + + if (viewport) { + viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8 }); + } + }; + + return { containerRef, viewportRef, listRef, scroll, scrollByPage }; +} + +function revealTab(viewport: HTMLElement, tab: HTMLElement) { + const bounds = viewport.getBoundingClientRect(); + const target = tab.getBoundingClientRect(); + + if (target.left < bounds.left) { + viewport.scrollBy({ left: target.left - bounds.left - 4 }); + } else if (target.right > bounds.right) { + viewport.scrollBy({ left: target.right - bounds.right + 4 }); + } +} diff --git a/packages/rdx-ui/src/locales/en.json b/packages/rdx-ui/src/locales/en.json index a1e74ce..cee2b10 100644 --- a/packages/rdx-ui/src/locales/en.json +++ b/packages/rdx-ui/src/locales/en.json @@ -1,4 +1,6 @@ { + "tabsScrollBackward": "Scroll tabs left", + "tabsScrollForward": "Scroll tabs right", "search": "Quick search", "placeholder": "Search by name…", "clear": "Clear filters", diff --git a/packages/rdx-ui/src/locales/es.json b/packages/rdx-ui/src/locales/es.json index d102f36..e43e1c0 100644 --- a/packages/rdx-ui/src/locales/es.json +++ b/packages/rdx-ui/src/locales/es.json @@ -1,4 +1,6 @@ { + "tabsScrollBackward": "Desplazar pestañas a la izquierda", + "tabsScrollForward": "Desplazar pestañas a la derecha", "search": "Búsqueda rápida", "placeholder": "Buscar por nombre…", "clear": "Limpiar filtros", diff --git a/packages/rdx-ui/src/scrollable-tabs.test.tsx b/packages/rdx-ui/src/scrollable-tabs.test.tsx new file mode 100644 index 0000000..fdbbcf0 --- /dev/null +++ b/packages/rdx-ui/src/scrollable-tabs.test.tsx @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ScrollableTabsList } from "./components/scrollable-tabs-list"; + +test("pestañas desplazables: conserva identidad, selección y etiquetas largas sin añadir focos al SSR", () => { + const items = [ + { value: "all", label: "Todo" }, + { value: "first", label: "Plantilla con nombre largo repetido" }, + { value: "second", label: "Plantilla con nombre largo repetido" }, + ]; + + for (const locale of ["es", "en"]) { + const html = renderToStaticMarkup( + + + Contenido seleccionado + + ); + + assert.equal((html.match(/role="tab"/g) ?? []).length, 3); + assert.ok(html.includes('aria-label="Familias"')); + assert.match(html, /]*aria-selected="true"[^>]*data-value="second"/); + assert.ok(html.includes("Contenido seleccionado")); + assert.ok(html.includes('data-slot="scrollable-tabs-viewport"')); + assert.equal(html.includes("Scroll tabs left"), false); + assert.equal(html.includes("Desplazar pestañas a la izquierda"), false); + } +});