Mejorar la accesibilidad y el comportamiento de las acciones en las tablas:

- Actualizar el disparador de acciones para usar solo un icono con etiqueta accesible.
- Alinear y anclar la columna de acciones a la derecha, asegurando que no se oculte ni se ordene.
- Ajustar las pruebas para reflejar estos cambios en la interfaz.
This commit is contained in:
David Arranz 2026-10-07 18:29:44 +02:00
parent d8ece23f9e
commit da21f90615
7 changed files with 98 additions and 12 deletions

View File

@ -130,7 +130,8 @@ body {
padding-inline: 12px;
border-radius: 8px;
}
.inventory-asset-selected {
.inventory-asset-selected,
.inventory-asset-selected [data-pinned="right"] {
background: var(--accent);
}
.inventory-asset-name {

View File

@ -108,3 +108,8 @@ Se declara @tanstack/react-table en el paquete compartido porque es quien consum
BrandCatalogPage reutiliza EntityListHeader/EntityTable con búsqueda, filtro de estado, columnas, ordenación y paginación. BrandForm reutiliza FormDialog, ContextHelp y FormActions tanto para la gestión global como para las altas relacionadas.
BrandSelect, dentro del módulo assets, adapta SelectWithAction al catálogo remoto: filtra marcas activas, conserva la selección inactiva actual y textos anteriores, ofrece alta rápida y respeta una sola anidación. El borrador padre se conserva y la nueva marca se selecciona por UUID. El componente genérico no conoce BrandDTO, consultas ni endpoints. Véase [Catálogo global de marcas](catalogo-global-de-marcas.md).
## Acciones de fila ancladas
EntityRowActions muestra un botón icon-sm con EllipsisIcon, etiqueta accesible y título traducidos, sin texto visible repetido. EntityTable reserva la identidad de columna actions para operaciones: siempre visible, sin búsqueda/ordenación y fijada a la derecha mediante columnPinning de TanStack Table. EntityTableContent renderiza primero las columnas de datos y después la columna anclada, con cabecera/celdas sticky right-0, alineación derecha, anchura compacta y fondo opaco para evitar solapamientos al desplazar la tabla. Todos los listados consumidores heredan este comportamiento.

View File

@ -24,7 +24,7 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
- El identificador principal abre la ficha existente si hay una. Usar enlace si existe una ruta real; botón si abre diálogo, detalle en memoria o panel. No inventar rutas.
- Representar estados mediante indicador visual y texto, nunca solo color. Mantener los avisos de revisión e información contextual.
- Las relaciones son navegables cuando el destino ya ofrece navegación adecuada; no introducir enlaces ficticios ni nuevas rutas.
- Acciones de fila al final, con el menú contextual accesible y las restricciones existentes. No convertir acciones de negocio en acciones masivas.
- Acciones de fila siempre al final, con cabecera y celdas alineadas a la derecha y columna anclada al borde derecho durante el desplazamiento horizontal. La columna actions no se oculta ni se ordena. El disparador muestra únicamente el icono de tres puntos; conserva etiqueta accesible y título, sin repetir «Acciones» en las filas. El menú contextual mantiene las restricciones existentes. No convertir acciones de negocio en acciones masivas.
- Selección múltiple solo cuando una operación real la necesita. No añadir casillas sin consumidor. Mantener separada la selección del activo para su panel lateral.
- Cancelar un modal conserva búsqueda, filtros, página y selección de la lista. Las actualizaciones continúan invalidando las consultas actuales.
@ -110,3 +110,10 @@ La primera columna tiene anchura mínima para evitar nombres ilegibles. El despl
- Navegación comprobada a persona, ticket y activo; edición/copia en modal y cancelación sin guardar. Las definiciones de serie no ofrecen edición y las plantillas globales no ofrecen edición desde una compañía.
- Español/inglés y anchura móvil de 390 px comprobados; el activo abre la ficha modal en móvil. En escritorio la página no presenta desbordamiento horizontal; la tabla conserva su propio desplazamiento.
- No se han creado ni alterado registros de negocio durante la revisión del navegador.
### Ajuste de acciones de fila (7 de octubre de 2026)
Disparador solo con icono de tres puntos, etiqueta accesible y título conservados. Columna actions siempre al final, compacta, alineada y anclada a la derecha; no admite ocultación, búsqueda u ordenación. El fondo opaco respeta hover, menú abierto y selección del inventario.
Verificación: 204 pruebas correctas, tipos y compilación correctos; los cinco archivos de código/estilos de este ajuste pasan Biome. Las comprobaciones globales conservan los errores anteriores. En navegador se han comprobado apertura/cancelación de edición, botón sin texto, posición de cabecera y celdas antes y después del desplazamiento horizontal a anchura móvil, y color del activo seleccionado. No se modificaron registros de negocio durante la revisión.

View File

@ -1,4 +1,5 @@
import { Menu } from "@base-ui/react/menu";
import { EllipsisIcon } from "lucide-react";
import type { ReactNode } from "react";
import { entityListLabels, entityListPages } from "../lib/entity-list";
@ -11,8 +12,12 @@ const ENTITY_PAGE_SIZES = [10, 25, 50, 100];
export function EntityRowActions({ children, label }: { children: ReactNode; label: string }) {
return (
<Menu.Root>
<Menu.Trigger aria-label={label} render={<Button size="sm" variant="ghost" />}>
{label} <span aria-hidden="true">···</span>
<Menu.Trigger
aria-label={label}
render={<Button size="icon-sm" variant="ghost" />}
title={label}
>
<EllipsisIcon aria-hidden="true" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner align="end" className="z-50" sideOffset={4}>

View File

@ -21,7 +21,10 @@ export function EntityTableContent<T extends { id: string }>({
rowClassName: ((item: T) => string) | undefined;
onSort: () => void;
}) {
const visibleColumns = table.getVisibleLeafColumns();
const visibleColumns = [
...table.getCenterVisibleLeafColumns(),
...table.getRightVisibleLeafColumns(),
];
const total = table.getFilteredRowModel().rows.length;
return (
<Table aria-label={title}>
@ -38,7 +41,8 @@ export function EntityTableContent<T extends { id: string }>({
? "descending"
: undefined
}
className="first:min-w-40"
className={entityColumnClass(column.getIsPinned() === "right", true)}
data-pinned={column.getIsPinned() || undefined}
key={column.id}
scope="col"
>
@ -73,10 +77,14 @@ export function EntityTableContent<T extends { id: string }>({
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow className={rowClassName?.(row.original)} key={row.id}>
<TableRow
className={`group/entity-row ${rowClassName?.(row.original) ?? ""}`}
key={row.id}
>
{visibleColumns.map((column) => (
<TableCell
className="h-14 first:min-w-40 max-w-lg whitespace-normal break-words px-3 py-3"
className={entityColumnClass(column.getIsPinned() === "right", false)}
data-pinned={column.getIsPinned() || undefined}
key={column.id}
>
{columns.find((item) => item.id === column.id)?.render(row.original)}
@ -96,3 +104,14 @@ export function EntityTableContent<T extends { id: string }>({
</Table>
);
}
function entityColumnClass(pinned: boolean, header: boolean) {
if (pinned) {
return header
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
: "sticky right-0 z-10 h-14 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))]";
}
return header
? "first:min-w-40"
: "h-14 first:min-w-40 max-w-lg whitespace-normal break-words px-3 py-3";
}

View File

@ -3,7 +3,7 @@ import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { EntityTable } from "./components/entity-list";
import { EntityAction, EntityRowActions, EntityTable } from "./components/entity-list";
import { entityListPages, entityListRange } from "./lib/entity-list";
const items = Array.from({ length: 21 }, (_, index) => ({
@ -94,3 +94,49 @@ test("el rango y la configuración también se traducen en inglés", () => {
assert.ok(html.includes("Columns"));
assert.ok(html.includes("Items per page"));
});
test("acciones: disparador de solo icono con etiqueta accesible y menú contextual", () => {
const html = renderToStaticMarkup(
<EntityRowActions label="Acciones de Ana">
<EntityAction onClick={() => undefined}>Editar</EntityAction>
</EntityRowActions>
);
assert.ok(html.includes('aria-label="Acciones de Ana"'));
assert.ok(html.includes('title="Acciones de Ana"'));
assert.ok(html.includes('aria-haspopup="menu"'));
assert.ok(html.includes("<svg"));
assert.equal(html.includes(">Acciones de Ana"), false);
});
test("acciones: siempre última, compacta y anclada a la derecha, aunque se declare primero", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[
{
id: "actions",
label: "Acciones",
hideable: true,
value: () => "Acción",
render: () => (
<EntityRowActions label="Acciones">
<EntityAction onClick={() => undefined}>Editar</EntityAction>
</EntityRowActions>
),
},
...columns,
]}
items={items.slice(0, 1)}
title="Personas"
/>
);
const header = html.match(/<thead[\s\S]*?<\/thead>/)?.[0] ?? "";
const row = html.match(/<tbody[\s\S]*?<\/tbody>/)?.[0] ?? "";
assert.ok(header.indexOf(">Nombre") < header.indexOf(">Acciones"));
assert.ok(row.indexOf("Persona 01") < row.indexOf('aria-label="Acciones"'));
assert.equal(html.split('data-pinned="right"').length - 1, 2);
assert.equal(html.split("sticky right-0").length - 1, 2);
assert.equal(header.includes('aria-label="Acciones"'), false);
assert.ok(header.includes("text-right"));
assert.ok(row.includes("text-right"));
assert.ok(row.includes("bg-card"));
});

View File

@ -45,9 +45,9 @@ export function useEntityTable<T extends { id: string }>({
id: column.id,
header: column.label,
accessorFn: column.value ?? (() => ""),
enableSorting: !!column.value,
enableGlobalFilter: !!column.value,
enableHiding: column.hideable !== false,
enableSorting: column.id !== "actions" && !!column.value,
enableGlobalFilter: column.id !== "actions" && !!column.value,
enableHiding: column.id !== "actions" && column.hideable !== false,
})),
[columns]
);
@ -58,6 +58,9 @@ export function useEntityTable<T extends { id: string }>({
state: {
sorting,
columnVisibility: visibility,
columnPinning: {
right: columns.filter((column) => column.id === "actions").map((column) => column.id),
},
pagination,
globalFilter: onSearchChange ? "" : currentSearch.trim(),
},