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:
parent
d8ece23f9e
commit
da21f90615
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user