feat: implement ScrollableTabsList component and integrate into asset and resource pages

This commit is contained in:
David Arranz 2026-10-15 19:13:34 +02:00
parent 37110d87ed
commit 2f733a3b63
10 changed files with 239 additions and 27 deletions

View File

@ -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. 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 Los campos optativos no muestran indicación de opcionalidad; tipo, unidad y avisos se
mantienen como metadatos de consulta. 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.

View File

@ -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. - 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. - 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 ## 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. 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.

View File

@ -1,6 +1,7 @@
import type { EntityColumn } from "@repo/rdx-ui/components/entity-list"; import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
import { EntityStatus, EntityTable } 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 type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -72,22 +73,21 @@ export function AssetList({
return ( return (
<Tabs <Tabs
className={layout === "fill" ? "min-h-112 flex-1 sm:min-h-96" : undefined} className={`min-w-0 max-w-full ${layout === "fill" ? "min-h-112 flex-1 sm:min-h-96" : ""}`}
onValueChange={onTemplateChange} onValueChange={onTemplateChange}
value={templateId ?? "all"} value={templateId ?? "all"}
> >
<div className="min-w-0 shrink-0 overflow-x-auto"> <ScrollableTabsList
<TabsList aria-label={t("filterByTemplate")} variant="line"> items={[
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger> { value: "all", label: t("allAssets") },
{[...templates].map(([id, name]) => ( ...[...templates].map(([value, label]) => ({ value, label })),
<TabsTrigger key={id} value={id}> ]}
{name} label={t("filterByTemplate")}
</TabsTrigger> locale={i18n.language}
))} value={templateId ?? "all"}
</TabsList> />
</div>
<TabsContent <TabsContent
className={layout === "fill" ? "flex min-h-0 flex-col" : undefined} className={`min-w-0 ${layout === "fill" ? "flex min-h-0 flex-col" : ""}`}
keepMounted keepMounted
value={templateId ?? "all"} value={templateId ?? "all"}
> >

View File

@ -6,9 +6,10 @@ import {
EntityStatus, EntityStatus,
EntityTable, EntityTable,
} from "@repo/rdx-ui/components/entity-list"; } 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 { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
import { Button } from "@repo/shadcn-ui/components/button"; 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 { useTranslation } from "react-i18next";
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon"; import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
@ -93,18 +94,17 @@ export function ResourcesPage(props: ResourcesPageProps) {
{t("retry")} {t("retry")}
</Button> </Button>
)} )}
<Tabs onValueChange={setTemplateId} value={tabValue}> <Tabs className="min-w-0 max-w-full" onValueChange={setTemplateId} value={tabValue}>
<div className="min-w-0 overflow-x-auto"> <ScrollableTabsList
<TabsList aria-label={t("filterByTemplate")} variant="line"> items={[
<TabsTrigger value="all">{t("allResources")}</TabsTrigger> { value: "all", label: t("allResources") },
{templates.map((template) => ( ...templates.map((template) => ({ value: template.id, label: template.values.name })),
<TabsTrigger key={template.id} value={template.id}> ]}
{template.values.name} label={t("filterByTemplate")}
</TabsTrigger> locale={i18n.language}
))} value={tabValue}
</TabsList> />
</div> <TabsContent className="min-w-0" keepMounted value={tabValue}>
<TabsContent keepMounted value={tabValue}>
<EntityTable <EntityTable
columns={[ columns={[
{ {

View File

@ -11,7 +11,7 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts" "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "1.8.0", "@base-ui/react": "1.8.0",

View File

@ -0,0 +1,77 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
import type { ReactNode } from "react";
import { useScrollableTabs } from "../hooks/use-scrollable-tabs";
import { ChevronLeftIcon, ChevronRightIcon } from "../lib/icons";
import english from "../locales/en.json";
import spanish from "../locales/es.json";
/** Barra horizontal de pestañas; se compone dentro de Tabs y conserva la navegación de Base UI. */
export function ScrollableTabsList({
label,
value,
locale = "es",
items,
}: {
label: string;
value: string;
locale?: string;
items: readonly { value: string; label: ReactNode }[];
}) {
const { containerRef, viewportRef, listRef, scroll, scrollByPage } = useScrollableTabs(value);
const labels = locale.startsWith("en") ? english : spanish;
return (
<div
className="flex w-full min-w-0 max-w-full shrink-0 items-center gap-1"
data-slot="scrollable-tabs"
ref={containerRef}
>
{scroll.overflow && (
<Button
aria-label={labels.tabsScrollBackward}
disabled={!scroll.backward}
onClick={() => scrollByPage(-1)}
size="icon-sm"
title={labels.tabsScrollBackward}
type="button"
variant="ghost"
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
)}
<div
className="relative min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-slot="scrollable-tabs-viewport"
ref={viewportRef}
>
<TabsList
aria-label={label}
className="w-max min-w-full justify-start [&>[data-slot=tabs-trigger]]:flex-none"
ref={listRef}
variant="line"
>
{items.map((item) => (
<TabsTrigger data-value={item.value} key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</div>
{scroll.overflow && (
<Button
aria-label={labels.tabsScrollForward}
disabled={!scroll.forward}
onClick={() => scrollByPage(1)}
size="icon-sm"
title={labels.tabsScrollForward}
type="button"
variant="ghost"
>
<ChevronRightIcon aria-hidden="true" />
</Button>
)}
</div>
);
}

View File

@ -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<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(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<HTMLElement>('[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 });
}
}

View File

@ -1,4 +1,6 @@
{ {
"tabsScrollBackward": "Scroll tabs left",
"tabsScrollForward": "Scroll tabs right",
"search": "Quick search", "search": "Quick search",
"placeholder": "Search by name…", "placeholder": "Search by name…",
"clear": "Clear filters", "clear": "Clear filters",

View File

@ -1,4 +1,6 @@
{ {
"tabsScrollBackward": "Desplazar pestañas a la izquierda",
"tabsScrollForward": "Desplazar pestañas a la derecha",
"search": "Búsqueda rápida", "search": "Búsqueda rápida",
"placeholder": "Buscar por nombre…", "placeholder": "Buscar por nombre…",
"clear": "Limpiar filtros", "clear": "Limpiar filtros",

View File

@ -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(
<Tabs value="second">
<ScrollableTabsList items={items} label="Familias" locale={locale} value="second" />
<TabsContent value="second">Contenido seleccionado</TabsContent>
</Tabs>
);
assert.equal((html.match(/role="tab"/g) ?? []).length, 3);
assert.ok(html.includes('aria-label="Familias"'));
assert.match(html, /<button[^>]*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);
}
});