diff --git a/apps/web/src/global.css b/apps/web/src/global.css index 54a76d7..1ec7c5f 100644 --- a/apps/web/src/global.css +++ b/apps/web/src/global.css @@ -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 { diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 8a5908a..feba31e 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -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. diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md index 8242cdd..112f40d 100644 --- a/docs/patron-de-listados-de-entidades.md +++ b/docs/patron-de-listados-de-entidades.md @@ -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. diff --git a/packages/shadcn-ui/src/components/entity-list-controls.tsx b/packages/shadcn-ui/src/components/entity-list-controls.tsx index 8c7ff6c..b0d8177 100644 --- a/packages/shadcn-ui/src/components/entity-list-controls.tsx +++ b/packages/shadcn-ui/src/components/entity-list-controls.tsx @@ -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 ( - }> - {label} + } + title={label} + > + diff --git a/packages/shadcn-ui/src/components/entity-table-content.tsx b/packages/shadcn-ui/src/components/entity-table-content.tsx index d15a581..e7dba23 100644 --- a/packages/shadcn-ui/src/components/entity-table-content.tsx +++ b/packages/shadcn-ui/src/components/entity-table-content.tsx @@ -21,7 +21,10 @@ export function EntityTableContent({ rowClassName: ((item: T) => string) | undefined; onSort: () => void; }) { - const visibleColumns = table.getVisibleLeafColumns(); + const visibleColumns = [ + ...table.getCenterVisibleLeafColumns(), + ...table.getRightVisibleLeafColumns(), + ]; const total = table.getFilteredRowModel().rows.length; return ( @@ -38,7 +41,8 @@ export function EntityTableContent({ ? "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({ ) : ( table.getRowModel().rows.map((row) => ( - + {visibleColumns.map((column) => ( {columns.find((item) => item.id === column.id)?.render(row.original)} @@ -96,3 +104,14 @@ export function EntityTableContent({
); } + +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"; +} diff --git a/packages/shadcn-ui/src/entity-list.test.tsx b/packages/shadcn-ui/src/entity-list.test.tsx index e919386..80c48c9 100644 --- a/packages/shadcn-ui/src/entity-list.test.tsx +++ b/packages/shadcn-ui/src/entity-list.test.tsx @@ -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( + + undefined}>Editar + + ); + 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("Acciones de Ana"), false); +}); + +test("acciones: siempre última, compacta y anclada a la derecha, aunque se declare primero", () => { + const html = renderToStaticMarkup( + "Acción", + render: () => ( + + undefined}>Editar + + ), + }, + ...columns, + ]} + items={items.slice(0, 1)} + title="Personas" + /> + ); + const header = html.match(//)?.[0] ?? ""; + const row = html.match(//)?.[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")); +}); diff --git a/packages/shadcn-ui/src/hooks/use-entity-table.ts b/packages/shadcn-ui/src/hooks/use-entity-table.ts index 23d593e..5ae4ba3 100644 --- a/packages/shadcn-ui/src/hooks/use-entity-table.ts +++ b/packages/shadcn-ui/src/hooks/use-entity-table.ts @@ -45,9 +45,9 @@ export function useEntityTable({ 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({ state: { sorting, columnVisibility: visibility, + columnPinning: { + right: columns.filter((column) => column.id === "actions").map((column) => column.id), + }, pagination, globalFilter: onSearchChange ? "" : currentSearch.trim(), },