feat: implement ScrollableTabsList component and integrate into asset and resource pages
This commit is contained in:
parent
37110d87ed
commit
2f733a3b63
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 (
|
||||
<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}
|
||||
value={templateId ?? "all"}
|
||||
>
|
||||
<div className="min-w-0 shrink-0 overflow-x-auto">
|
||||
<TabsList aria-label={t("filterByTemplate")} variant="line">
|
||||
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger>
|
||||
{[...templates].map(([id, name]) => (
|
||||
<TabsTrigger key={id} value={id}>
|
||||
{name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollableTabsList
|
||||
items={[
|
||||
{ value: "all", label: t("allAssets") },
|
||||
...[...templates].map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
label={t("filterByTemplate")}
|
||||
locale={i18n.language}
|
||||
value={templateId ?? "all"}
|
||||
/>
|
||||
<TabsContent
|
||||
className={layout === "fill" ? "flex min-h-0 flex-col" : undefined}
|
||||
className={`min-w-0 ${layout === "fill" ? "flex min-h-0 flex-col" : ""}`}
|
||||
keepMounted
|
||||
value={templateId ?? "all"}
|
||||
>
|
||||
|
||||
@ -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")}
|
||||
</Button>
|
||||
)}
|
||||
<Tabs onValueChange={setTemplateId} value={tabValue}>
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<TabsList aria-label={t("filterByTemplate")} variant="line">
|
||||
<TabsTrigger value="all">{t("allResources")}</TabsTrigger>
|
||||
{templates.map((template) => (
|
||||
<TabsTrigger key={template.id} value={template.id}>
|
||||
{template.values.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent keepMounted value={tabValue}>
|
||||
<Tabs className="min-w-0 max-w-full" onValueChange={setTemplateId} value={tabValue}>
|
||||
<ScrollableTabsList
|
||||
items={[
|
||||
{ value: "all", label: t("allResources") },
|
||||
...templates.map((template) => ({ value: template.id, label: template.values.name })),
|
||||
]}
|
||||
label={t("filterByTemplate")}
|
||||
locale={i18n.language}
|
||||
value={tabValue}
|
||||
/>
|
||||
<TabsContent className="min-w-0" keepMounted value={tabValue}>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
|
||||
77
packages/rdx-ui/src/components/scrollable-tabs-list.tsx
Normal file
77
packages/rdx-ui/src/components/scrollable-tabs-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
packages/rdx-ui/src/hooks/use-scrollable-tabs.ts
Normal file
88
packages/rdx-ui/src/hooks/use-scrollable-tabs.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
{
|
||||
"tabsScrollBackward": "Scroll tabs left",
|
||||
"tabsScrollForward": "Scroll tabs right",
|
||||
"search": "Quick search",
|
||||
"placeholder": "Search by name…",
|
||||
"clear": "Clear filters",
|
||||
|
||||
@ -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",
|
||||
|
||||
32
packages/rdx-ui/src/scrollable-tabs.test.tsx
Normal file
32
packages/rdx-ui/src/scrollable-tabs.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user