feat: enhance chapter and detail row interactions with improved animations and visibility controls

- Adjusted drag handle alignment in QuoteChaptersDataTable for consistent width and positioning.
- Implemented height and opacity transitions in useChapterCollapse for smoother chapter collapsing.
- Updated QuoteItemsSortableTableRow to identify active rows with data attributes and apply consistent styling.
- Refined detail row selection to limit checkboxes to detail rows only, improving user experience.
- Enhanced visibility of controls on hover and focus for both chapter titles and detail rows.
- Improved spacing and alignment for chapter titles and associated controls, ensuring a cohesive layout.
- Added tests to validate new behaviors and interactions, ensuring functionality remains intact.
This commit is contained in:
David Arranz 2026-10-19 16:56:37 +02:00
parent bc34a073ad
commit 3b660bf27e
8 changed files with 332 additions and 245 deletions

View File

@ -11,13 +11,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
EmptyContent,
Label,
Select,
SelectContent,
SelectItem,
@ -29,12 +29,12 @@ import {
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/ui/table";
import {
DndContext,
closestCenter,
pointerWithin,
DragEndEvent,
KeyboardCoordinateGetter,
KeyboardSensor,
PointerSensor,
closestCenter,
pointerWithin,
useDroppable,
useSensor,
useSensors,
@ -48,37 +48,31 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
CopyIcon,
FolderIcon,
FolderOpenIcon,
GripVerticalIcon,
Trash2Icon,
} from "lucide-react";
import { CopyIcon, FolderIcon, FolderOpenIcon, GripVerticalIcon, Trash2Icon } from "lucide-react";
import { Fragment, ReactNode, useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
chapterSubtotal,
duplicateChapterSelection,
EditorChapter,
EditorItem,
chapterSubtotal,
duplicateChapterSelection,
insertChapterItem,
moveChapterItems,
moveChapters,
removeChapterSelection,
ungroupChapters,
} from "../chapterEditor";
import { ChapterEditorController } from "../hooks/useChapterEditor";
import { useChapterCollapse } from "../hooks/useChapterCollapse";
import { ChapterEditorController } from "../hooks/useChapterEditor";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { RowIdData } from "./QuoteItemsSortableDataTable";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
import { RowIdData } from "./QuoteItemsSortableDataTable";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
type Selection = { chapters: string[]; indexes: number[] };
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
@ -110,7 +104,11 @@ const keyboardCoordinates: KeyboardCoordinateGetter = (
};
function ChapterContent({ children }: { children: ReactNode }) {
return <div data-chapter-content className='min-w-0'>{children}</div>;
return (
<div data-chapter-content className='min-w-0'>
{children}
</div>
);
}
// Reserve the expand column in detail rows so all controls stay aligned.
@ -118,7 +116,15 @@ function ChapterSpacerCell() {
return <TableCell className='p-0' data-chapter-column='chapter_tree' />;
}
function EmptyChapterTarget({ id, columns, hidden, collapsed, onAddRow, onAddArticle, onAddBlock }: {
function EmptyChapterTarget({
id,
columns,
hidden,
collapsed,
onAddRow,
onAddArticle,
onAddBlock,
}: {
id: string;
columns: number;
hidden: boolean;
@ -147,7 +153,7 @@ function EmptyChapterTarget({ id, columns, hidden, collapsed, onAddRow, onAddArt
<EmptyTitle>{t("quotes.chapters.empty_title")}</EmptyTitle>
<EmptyDescription>{t("quotes.chapters.empty")}</EmptyDescription>
</EmptyHeader>
<EmptyContent className='max-w-none flex-row flex-wrap justify-center gap-2'>
<EmptyContent className='flex-row flex-wrap justify-center gap-2 max-w-none'>
<AppendEmptyRowButton size='sm' onClick={onAddRow} />
<AppendCatalogArticleRowButton size='sm' onClick={onAddArticle} />
<AppendBlockRowButton size='sm' onClick={onAddBlock} />
@ -164,24 +170,18 @@ function ChapterGroup({
index,
columns,
children,
selected,
onSelect,
onActivate,
onDuplicate,
onRemove,
mixed,
subtotal,
}: {
chapter: EditorChapter;
index: number;
columns: number;
children: (collapsed: boolean, hidden: boolean) => ReactNode;
selected: boolean;
onSelect: (selected: boolean) => void;
onActivate: () => void;
onDuplicate: () => void;
onRemove: () => void;
mixed: boolean;
subtotal: string;
}) {
const { t } = useTranslation();
@ -195,14 +195,23 @@ function ChapterGroup({
transform,
transition,
isDragging,
} = useSortable({ id: `chapter:${chapter.id}`, disabled: mixed });
} = useSortable({ id: `chapter:${chapter.id}` });
return (
<TableBody
ref={(node) => {
setNodeRef(node);
ref.current = node;
}}
className='bg-background [&>tr:not([data-chapter-gap])>td]:border-b [&>tr:not([data-chapter-gap])>td]:border-b-border'
className={`bg-background
[&>tr:not([data-chapter-gap]):not([data-active-item])>td]:border-b
[&>tr:not([data-chapter-gap]):not([data-active-item])>td]:border-b-border
[&>tr[data-active-item]>td]:border-destructive/30
[&>tr[data-active-item]>td:first-child]:border-l-4
[&>tr[data-active-item]>td:first-child]:border-l-destructive
[&>tr[data-active-item]>td:last-child]:border-r
[&>tr[data-active-item=detail]>td]:border-t
[&>tr[data-active-item=detail]>td]:border-b-0
[&>tr[data-active-item=actions]>td]:border-b`}
style={{
transform: CSS.Translate.toString(transform),
transition,
@ -215,16 +224,16 @@ function ChapterGroup({
</TableRow>
)}
<TableRow
className='bg-muted hover:bg-muted [&>td]:border-t [&>td]:py-3 [&>td:first-child]:rounded-tl-md [&>td:last-child]:rounded-tr-md'
className='group/chapter bg-muted hover:bg-muted [&>td]:border-t [&>td]:py-3 [&>td:first-child]:rounded-tl-md [&>td:last-child]:rounded-tr-md'
data-chapter-id={chapter.id}
>
<TableCell className='px-1'>
<div className='relative h-8 w-8'>
<TableCell className='pl-3.5 pr-1'>
<div className='relative w-8 h-8'>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 text-muted-foreground'
className='w-8 h-8 text-muted-foreground'
aria-expanded={!collapsed}
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
number: index + 1,
@ -235,9 +244,9 @@ function ChapterGroup({
}}
>
{collapsed ? (
<FolderIcon className='h-5 w-5' aria-hidden='true' />
<FolderIcon className='w-5 h-5' aria-hidden='true' />
) : (
<FolderOpenIcon className='h-5 w-5' aria-hidden='true' />
<FolderOpenIcon className='w-5 h-5' aria-hidden='true' />
)}
</Button>
<Badge
@ -249,30 +258,7 @@ function ChapterGroup({
</Badge>
</div>
</TableCell>
<TableCell className='px-2'>
<Button
type='button'
variant='ghost'
size='icon'
disabled={mixed}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className='h-8 w-4 touch-none cursor-grab'
onFocus={onActivate}
aria-label={t("quotes.chapters.move", { number: index + 1 })}
>
<GripVerticalIcon className='h-4 w-4' />
</Button>
</TableCell>
<TableCell className='px-2'>
<Checkbox
checked={selected}
onCheckedChange={(value) => onSelect(Boolean(value))}
aria-label={t("quotes.chapters.select", { number: index + 1 })}
/>
</TableCell>
<TableCell colSpan={columns - 3} className='px-2'>
<TableCell colSpan={columns - 1} className='px-2'>
<div className='flex items-start gap-2'>
<Controller
key={`${chapter.id}:${index}`}
@ -283,7 +269,7 @@ function ChapterGroup({
Boolean(value?.trim()) || t("quotes.chapters.title_required"),
}}
render={({ field, fieldState }) => (
<div className='min-w-0 flex-1'>
<div className='-ml-px flex-1 min-w-0'>
<Textarea
{...field}
value={field.value ?? ""}
@ -291,7 +277,7 @@ function ChapterGroup({
onFocus={onActivate}
aria-label={t("quotes.chapters.title", { number: index + 1 })}
aria-invalid={Boolean(fieldState.error)}
className='min-h-10 border-transparent bg-transparent font-semibold shadow-none hover:border-input hover:bg-background focus-visible:border-input focus-visible:bg-background'
className='px-0 font-semibold bg-transparent border-transparent shadow-none min-h-10 hover:border-input hover:bg-background focus-visible:border-input focus-visible:bg-background'
/>
{fieldState.error && (
<p role='alert' className='text-sm text-destructive'>
@ -301,23 +287,38 @@ function ChapterGroup({
</div>
)}
/>
<Button
type='button'
variant='ghost'
size='icon'
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className='w-10 h-10 shrink-0 touch-none cursor-grab opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
onFocus={onActivate}
aria-label={t("quotes.chapters.move", { number: index + 1 })}
>
<GripVerticalIcon className='w-4 h-4' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
onClick={onDuplicate}
className='opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
aria-label={t("quotes.chapters.duplicate", { number: index + 1 })}
>
<CopyIcon className='h-4 w-4' />
<CopyIcon className='w-4 h-4' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
onClick={onRemove}
className='opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
aria-label={t("quotes.chapters.remove", { number: index + 1 })}
>
<Trash2Icon className='h-4 w-4' />
<Trash2Icon className='w-4 h-4' />
</Button>
</div>
</TableCell>
@ -331,7 +332,7 @@ function ChapterGroup({
<TableCell colSpan={columns - 2} className='pl-4 text-sm text-muted-foreground'>
{t("quotes.chapters.subtotal", { number: index + 1, description: chapter.description })}
</TableCell>
<TableCell className='whitespace-nowrap text-right text-base'>{subtotal}</TableCell>
<TableCell className='text-base text-right whitespace-nowrap'>{subtotal}</TableCell>
</TableRow>
</TableBody>
);
@ -365,19 +366,14 @@ export function QuoteChaptersDataTable({
const { t } = useTranslation();
const { watch, setValue } = useFormContext();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [chapterSelection, setChapterSelection] = useState<string[]>([]);
const [activeRow, setActiveRow] = useState<string | null>(null);
const [deleting, setDeleting] = useState<Selection | null>(null);
const [ungrouping, setUngrouping] = useState(false);
const [targetId, setTargetId] = useState("");
const { chapters, items } = controller;
const selectedIndexes = data.flatMap((row, index) => (rowSelection[row.id] ? [index] : []));
const mixed =
chapterSelection.length > 0 &&
selectedIndexes.some((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""));
const clear = () => {
setRowSelection({});
setChapterSelection([]);
setActiveRow(null);
};
const apply = (state: Parameters<typeof controller.apply>[0]) => {
@ -427,11 +423,8 @@ export function QuoteChaptersDataTable({
apply(next);
setDeleting(null);
};
const selection = { chapters: chapterSelection, indexes: selectedIndexes };
const selectedCount =
chapterSelection.length +
selectedIndexes.filter((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""))
.length;
const selection: Selection = { chapters: [], indexes: selectedIndexes };
const selectedCount = selectedIndexes.length;
const insert = (index: number, after = false) => {
const state = controller.read();
apply(
@ -444,17 +437,20 @@ export function QuoteChaptersDataTable({
)
);
};
const chapterColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(() => [
{
id: "chapter_tree",
header: () => null,
cell: () => null,
size: 44,
enableHiding: false,
enableSorting: false,
},
...columns,
], [columns]);
const chapterColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(
() => [
{
id: "chapter_tree",
header: () => null,
cell: () => null,
size: 56,
enableHiding: false,
enableSorting: false,
},
...columns,
],
[columns]
);
const table = useReactTable({
data,
columns: chapterColumns,
@ -494,17 +490,16 @@ export function QuoteChaptersDataTable({
useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates })
);
const dragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id || mixed) return;
if (!over || active.id === over.id) return;
const state = controller.read();
if (isChapter(active.id)) {
if (!isChapter(over.id)) return;
const from = String(active.id).slice(8);
const to = String(over.id).slice(8);
const moving = chapterSelection.includes(from) ? chapterSelection : [from];
apply(
moveChapters(
state,
moving,
[from],
to,
state.chapters.findIndex((chapter) => chapter.id === from) <
state.chapters.findIndex((chapter) => chapter.id === to)
@ -543,12 +538,7 @@ export function QuoteChaptersDataTable({
sensors={sensors}
onDragEnd={dragEnd}
onDragStart={({ active }) => {
if (
isChapter(active.id)
? !chapterSelection.includes(String(active.id).slice(8))
: !rowSelection[active.id]
)
clear();
if (isChapter(active.id) || !rowSelection[active.id]) clear();
}}
collisionDetection={(args) => {
const valid = {
@ -587,16 +577,19 @@ export function QuoteChaptersDataTable({
<Button type='button' variant='ghost' size='sm' onClick={onAddChapter}>
{t("quotes.chapters.add")}
</Button>
<Button type='button' variant='ghost' size='sm' onClick={() => setUngrouping(true)}>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setUngrouping(true)}
>
{t("quotes.chapters.ungroup")}
</Button>
</>
}
viewControls={
<div className='flex min-h-9 items-center gap-2'>
<Label htmlFor='chapter-line-amounts'>
{t("quotes.chapters.show_amounts")}
</Label>
<div className='flex items-center gap-2 min-h-9'>
<Label htmlFor='chapter-line-amounts'>{t("quotes.chapters.show_amounts")}</Label>
<Switch
id='chapter-line-amounts'
checked={watch("show_chapter_line_amounts") ?? true}
@ -607,14 +600,9 @@ export function QuoteChaptersDataTable({
</div>
}
/>
{mixed && (
<p role='status' className='text-sm'>
{t("quotes.chapters.mixed_selection")}
</p>
)}
</CardHeader>
<CardContent>
<Table className='table-fixed border-separate border-spacing-0'>
<Table className='border-separate table-fixed border-spacing-0'>
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
@ -634,15 +622,6 @@ export function QuoteChaptersDataTable({
chapter={chapter}
index={chapterIndex}
columns={countColumns}
mixed={mixed}
selected={chapterSelection.includes(chapter.id)}
onSelect={(value) =>
setChapterSelection((previous) =>
value
? [...previous, chapter.id]
: previous.filter((id) => id !== chapter.id)
)
}
onActivate={() => controller.setActiveId(chapter.id)}
onDuplicate={() => duplicate({ chapters: [chapter.id], indexes: [] })}
onRemove={() => remove({ chapters: [chapter.id], indexes: [] })}
@ -650,44 +629,49 @@ export function QuoteChaptersDataTable({
>
{(collapsed, hidden) => (
<>
{rows.length > 0 && table.getHeaderGroups().map((group) => (
<TableRow
key={group.id}
hidden={hidden}
data-chapter-columns={chapter.id}
className='bg-muted/30 hover:bg-muted/30'
>
{group.headers.map((header) => (
<TableHead key={header.id} scope='col' className='px-2'>
<ChapterContent>
{header.column.id === "select" ? (
<Checkbox
aria-label={t("quotes.chapters.select_details", {
number: chapterIndex + 1,
})}
checked={
rows.every((row) => row.getIsSelected()) ||
(rows.some((row) => row.getIsSelected()) && "indeterminate")
}
onCheckedChange={(checked) => {
setRowSelection((previous) => {
const next = { ...previous };
for (const row of rows) {
if (checked) next[row.id] = true;
else delete next[row.id];
}
return next;
});
}}
/>
) : header.isPlaceholder ? null : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</ChapterContent>
</TableHead>
))}
</TableRow>
))}
{rows.length > 0 &&
table.getHeaderGroups().map((group) => (
<TableRow
key={group.id}
hidden={hidden}
data-chapter-columns={chapter.id}
className='bg-muted/30 hover:bg-muted/30'
>
{group.headers.map((header) => (
<TableHead key={header.id} scope='col' className='px-2'>
<ChapterContent>
{header.column.id === "select" ? (
<Checkbox
aria-label={t("quotes.chapters.select_details", {
number: chapterIndex + 1,
})}
checked={
rows.every((row) => row.getIsSelected()) ||
(rows.some((row) => row.getIsSelected()) &&
"indeterminate")
}
onCheckedChange={(checked) => {
setRowSelection((previous) => {
const next = { ...previous };
for (const row of rows) {
if (checked) next[row.id] = true;
else delete next[row.id];
}
return next;
});
}}
/>
) : header.isPlaceholder ? null : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
</ChapterContent>
</TableHead>
))}
</TableRow>
))}
<SortableContext
items={collapsed ? [] : rows.map((row) => row.id)}
strategy={verticalListSortingStrategy}
@ -697,7 +681,7 @@ export function QuoteChaptersDataTable({
<QuoteItemsSortableTableRow
id={row.id}
hidden={hidden}
disabled={collapsed || mixed || chapterSelection.length > 0}
disabled={collapsed}
isActive={activeRow === row.id}
onActivate={(event) => {
if ((event.target as HTMLElement).closest('[role="checkbox"]'))
@ -706,23 +690,35 @@ export function QuoteChaptersDataTable({
controller.setActiveId(chapter.id);
}}
>
{row.getVisibleCells().map((cell) => cell.column.id === "chapter_tree" ? (
<ChapterSpacerCell key={cell.id} />
) : (
<TableCell
key={cell.id}
className={cell.column.id === "description" ? "py-2 pl-4 pr-2 align-top" : "px-2 py-2 align-top"}
>
<ChapterContent>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</ChapterContent>
</TableCell>
))}
{row.getVisibleCells().map((cell) =>
cell.column.id === "chapter_tree" ? (
<ChapterSpacerCell key={cell.id} />
) : (
<TableCell
key={cell.id}
className={
cell.column.id === "description"
? "py-2 pl-4 pr-2 align-top"
: "px-2 py-2 align-top"
}
>
<ChapterContent>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</ChapterContent>
</TableCell>
)
)}
</QuoteItemsSortableTableRow>
{activeRow === row.id && (
<TableRow hidden={hidden}>
<ChapterSpacerCell />
<TableCell colSpan={countColumns - 1}>
<TableRow
hidden={hidden}
data-active-item='actions'
className='bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5'
>
<TableCell colSpan={countColumns} className='px-2 py-2'>
<ChapterContent>
<QuoteItemsInlineRowActions
confirmDelete={false}
@ -744,7 +740,9 @@ export function QuoteChaptersDataTable({
collapsed={collapsed}
onAddRow={() => {
controller.setActiveId(chapter.id);
apply(insertChapterItem(controller.read(), defaultItem, chapter.id));
apply(
insertChapterItem(controller.read(), defaultItem, chapter.id)
);
}}
onAddArticle={() => {
controller.setActiveId(chapter.id);
@ -793,7 +791,8 @@ export function QuoteChaptersDataTable({
<SelectContent>
{destinations.map((chapter) => (
<SelectItem key={chapter.id} value={chapter.id}>
{chapters.findIndex((entry) => entry.id === chapter.id) + 1}. {chapter.description}
{chapters.findIndex((entry) => entry.id === chapter.id) + 1}.{" "}
{chapter.description}
</SelectItem>
))}
</SelectContent>

View File

@ -30,6 +30,8 @@ export const QuoteItemsSortableDragHandleCell = ({
className={cn(
isDragging ? "cursor-grabbing" : "cursor-grab",
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground touch-none",
"transition-opacity motion-reduce:transition-none group-hover/detail:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100",
isDragging ? "opacity-100" : "opacity-0",
className
)}
{...attributes}

View File

@ -84,12 +84,13 @@ export function QuoteItemsSortableTableRow({
key={id}
id={String(id)}
hidden={hidden}
data-active-item={isActive ? "detail" : undefined}
className={cn(
isDragging ? "opacity-60" : "opacity-100",
isActive
? "border border-destructive/30 border-b-0 border-l-4 border-l-destructive bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5"
: "hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent",
"m-0"
"group/detail m-0"
)}
ref={setNodeRef}
style={style}

View File

@ -318,11 +318,9 @@ it("moves a whole chapter with its details, and details to an empty chapter", ()
);
});
it("duplicates a selected chapter once and can return to free mode preserving every detail", () => {
it("duplicates a chapter using its action and can return to free mode preserving every detail", () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
fireEvent.click(screen.getByLabelText("Seleccionar fila"));
fireEvent.click(screen.getByText("Duplicar"));
fireEvent.click(screen.getByLabelText("Duplicar capítulo 1"));
expect(snapshot().chapters).toHaveLength(3);
expect(snapshot().items).toHaveLength(2);
expect(snapshot().items[1].chapter_id).not.toBe(chapterA);
@ -410,22 +408,19 @@ it("deletes chapter content only after explicit confirmation", () => {
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
});
it("disables mixed chapter/detail dragging without disabling selection actions", () => {
it("selects only detail rows and keeps chapter controls available", () => {
render(<Harness grouped />);
fireEvent.focus(screen.getByLabelText("Título del capítulo 2"));
fireEvent.click(screen.getByText("Añadir fila"));
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
const row = screen.getByLabelText("items.1.description").closest("tr")!;
for (const heading of document.querySelectorAll("[data-chapter-id]")) {
expect(heading.querySelector('[role="checkbox"]')).toBeNull();
}
const row = screen.getByLabelText("items.0.description").closest("tr")!;
fireEvent.click(within(row).getByLabelText("Seleccionar fila"));
expect(
screen.getByText("Para mover la selección, selecciona solo capítulos o solo detalles.")
).toBeTruthy();
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(true);
drop(`chapter:${chapterA}`, `chapter:${chapterB}`);
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([
chapterA,
chapterB,
]);
expect(within(row).getByLabelText("Seleccionar fila").getAttribute("aria-checked")).toBe("true");
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(screen.getByText("Duplicar"));
expect(snapshot().chapters).toHaveLength(2);
expect(snapshot().items).toHaveLength(2);
expect(snapshot().items.every((entry: { chapter_id: string }) => entry.chapter_id === chapterA)).toBe(true);
});
it("uses valid keyboard destinations and rejects pointer drops outside chapter contents", () => {
@ -562,7 +557,7 @@ it.each([true, false])("orders expand, drag and select controls with the expand
const heading = table.querySelector<HTMLTableRowElement>(`[data-chapter-id="${chapterA}"]`)!;
expect(screen.getByLabelText("Plegar capítulo 1").closest("td")).toBe(heading.cells[0]);
expect(screen.getByLabelText("Mover capítulo 1").closest("td")).toBe(heading.cells[1]);
expect(screen.getByLabelText("Seleccionar capítulo 1").closest("td")).toBe(heading.cells[2]);
expect(heading.querySelector('[role="checkbox"]')).toBeNull();
}
});
@ -644,8 +639,8 @@ it("shows chapter numbers as badges on the folder controls, including after reor
expect(badge.textContent).toBe(String(number));
expect(toggle.querySelector("svg")).toBeTruthy();
expect(toggle.getAttribute("aria-label")).toContain(String(number));
expect(heading.cells[3].querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
expect(heading.cells[3].textContent).not.toMatch(/^\s*\d+/);
expect(heading.cells[1].querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
expect(heading.cells[1].textContent).not.toMatch(/^\s*\d+/);
};
assertBadge(chapterA, 1);
assertBadge(chapterB, 2);

View File

@ -12,14 +12,14 @@ import { useToast } from "@/ui/use-toast";
import { CurrencyData, Language, Quantity, UnitPrice } from "@shared/contexts";
import { ColumnDef } from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { addChapter, EditorItem } from "../../chapterEditor";
import { useChapterEditor } from "../../hooks/useChapterEditor";
import { QuoteChapterCreateDialog } from "../QuoteChapterCreateDialog";
import { QuoteChaptersDataTable } from "../QuoteChaptersDataTable";
import { useDetailColumns } from "../../hooks/useDetailColumns";
import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
import { QuoteChapterCreateDialog } from "../QuoteChapterCreateDialog";
import { QuoteChaptersDataTable } from "../QuoteChaptersDataTable";
import { QuoteItemsSortableDataTable, RowIdData } from "../QuoteItemsSortableDataTable";
import { BlocksPickerDialog } from "./BlocksPickerDialog/BlocksPickerDialog";
import { CatalogPickerDialog } from "./CatalogPickerDialog";
@ -39,8 +39,8 @@ export const QuoteDetailsCardEditor = ({
const documentLanguageCode = watch("lang_code") || Language.DEFAULT_LANGUAGE_CODE;
const showLineDiscountColumn = watch("show_line_discount_column") ?? true;
const hasLineDiscounts = Boolean(
getValues("items")?.some((item: { discount?: { amount?: number | null } }) =>
Number(item.discount?.amount) > 0
getValues("items")?.some(
(item: { discount?: { amount?: number | null } }) => Number(item.discount?.amount) > 0
)
);
@ -56,12 +56,16 @@ export const QuoteDetailsCardEditor = ({
const chapterEditor = useChapterEditor(fieldActions.replace);
const [createChapterOpen, setCreateChapterOpen] = useState(false);
const appendPicked = useCallback((item: unknown) => {
if (chapterEditor.chapters.length) chapterEditor.appendPickedItem(item as EditorItem);
else fieldActions.append(item);
}, [chapterEditor, fieldActions]);
const appendPicked = useCallback(
(item: unknown) => {
if (chapterEditor.chapters.length) chapterEditor.appendPickedItem(item as EditorItem);
else fieldActions.append(item);
},
[chapterEditor, fieldActions]
);
const detailColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(() => [
const detailColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(
() => [
/*{
id: "row_id" as const,
header: () => (
@ -95,17 +99,12 @@ export const QuoteDetailsCardEditor = ({
cell: ({ row: { index, original } }) => {
if (original?.id_article) {
return (
<div className='min-h-10 whitespace-pre-wrap px-3 py-2 text-sm'>
<div className='px-3 py-2 text-sm whitespace-pre-wrap min-h-10'>
{original.localized_description ?? original.description}
</div>
);
}
return (
<FormTextAreaField
autoSize
name={`items.${index}.description`}
/>
);
return <FormTextAreaField autoSize name={`items.${index}.description`} />;
},
minSize: 200,
size: 400,
@ -118,11 +117,7 @@ export const QuoteDetailsCardEditor = ({
),
cell: ({ row: { index } }) => {
return (
<FormQuantityField
scale={0}
className='text-right'
name={`items.${index}.quantity`}
/>
<FormQuantityField scale={0} className='text-right' name={`items.${index}.quantity`} />
);
},
size: 75,
@ -205,13 +200,13 @@ export const QuoteDetailsCardEditor = ({
},
size: 150,
},
], [currency, language, t]);
const columns = useDetailColumns(detailColumns,
{
enableDragHandleColumn: true,
enableSelectionColumn: true,
}
],
[currency, language, t]
);
const columns = useDetailColumns(detailColumns, {
enableDragHandleColumn: true,
enableSelectionColumn: true,
});
const handleAppendCatalogArticle = useCallback(
(article: any, quantity: number = 1) => {
@ -266,29 +261,67 @@ export const QuoteDetailsCardEditor = ({
const defaultLayout = [265, 440, 655];
const navCollapsedSize = 4;
const pickCatalogArticle = (chapterId?: string) => { chapterEditor.capturePickerTarget(chapterId); setArticlePickerDialogOpen(true); };
const pickBlock = (chapterId?: string) => { chapterEditor.capturePickerTarget(chapterId); setBlockPickerDialogOpen(true); };
const onShowLineDiscountColumnChange = (value: boolean) => setValue("show_line_discount_column", value, { shouldDirty: true });
const pickCatalogArticle = (chapterId?: string) => {
chapterEditor.capturePickerTarget(chapterId);
setArticlePickerDialogOpen(true);
};
const pickBlock = (chapterId?: string) => {
chapterEditor.capturePickerTarget(chapterId);
setBlockPickerDialogOpen(true);
};
const onShowLineDiscountColumnChange = (value: boolean) =>
setValue("show_line_discount_column", value, { shouldDirty: true });
const table = chapterEditor.chapters.length ? (
<QuoteChaptersDataTable controller={chapterEditor} columns={columns} data={fields}
defaultItem={defaultValues?.items[0]} currencyCode={currency.code}
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
<QuoteChaptersDataTable
controller={chapterEditor}
columns={columns}
data={fields}
defaultItem={defaultValues?.items[0]}
currencyCode={currency.code}
showLineDiscountColumn={showLineDiscountColumn}
hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
onAddChapter={() => setCreateChapterOpen(true)} pickCatalogArticle={pickCatalogArticle} pickBlock={pickBlock} />
onAddChapter={() => setCreateChapterOpen(true)}
pickCatalogArticle={pickCatalogArticle}
pickBlock={pickBlock}
/>
) : (
<QuoteItemsSortableDataTable actions={{ ...fieldActions, getItem: (rowIndex) => getValues(`items.${rowIndex}`), pickCatalogArticle, pickBlock }}
columns={columns} data={fields} defaultValues={defaultValues}
extraControls={<Button type="button" variant="ghost" size="sm" onClick={() => setCreateChapterOpen(true)}>{t("quotes.chapters.add")}</Button>}
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange} />
<QuoteItemsSortableDataTable
actions={{
...fieldActions,
getItem: (rowIndex) => getValues(`items.${rowIndex}`),
pickCatalogArticle,
pickBlock,
}}
columns={columns}
data={fields}
defaultValues={defaultValues}
extraControls={
<Button type='button' variant='ghost' size='sm' onClick={() => setCreateChapterOpen(true)}>
{t("quotes.chapters.add")}
</Button>
}
showLineDiscountColumn={showLineDiscountColumn}
hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
/>
);
const createChapterDialog = (
<QuoteChapterCreateDialog
open={createChapterOpen}
onOpenChange={setCreateChapterOpen}
onCreate={(description) => {
const before = chapterEditor.read();
const next = addChapter(before, description, chapterEditor.activeId);
chapterEditor.apply(next);
chapterEditor.focusTitle(
next.chapters.find(
(chapter) => !before.chapters.some((entry) => entry.id === chapter.id)
)!.id
);
}}
/>
);
const createChapterDialog = <QuoteChapterCreateDialog open={createChapterOpen} onOpenChange={setCreateChapterOpen}
onCreate={(description) => {
const before = chapterEditor.read();
const next = addChapter(before, description, chapterEditor.activeId);
chapterEditor.apply(next);
chapterEditor.focusTitle(next.chapters.find((chapter) => !before.chapters.some((entry) => entry.id === chapter.id))!.id);
}} />;
if (pickerMode === "dialog") {
return (

View File

@ -18,7 +18,9 @@ export function useChapterCollapse() {
return;
}
const currentRevision = ++revision.current;
const contents = Array.from(ref.current?.querySelectorAll<HTMLElement>("[data-chapter-content]") ?? []);
const contents = Array.from(
ref.current?.querySelectorAll<HTMLElement>("[data-chapter-content]") ?? []
);
const rows = Array.from(new Set(contents.map((content) => content.closest("tr")!)));
for (const row of rows) {
row.toggleAttribute("inert", collapsed);
@ -44,8 +46,11 @@ export function useChapterCollapse() {
animations.current.forEach((animation) => animation.cancel());
animations.current = [];
if (!contents.length || typeof contents[0].animate !== "function" ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
if (
!contents.length ||
typeof contents[0].animate !== "function" ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
) {
setHidden(collapsed);
return;
}
@ -62,8 +67,16 @@ export function useChapterCollapse() {
const options: KeyframeAnimationOptions = { duration, easing: "ease-in-out", fill: "both" };
animations.current = [
...contents.map((content, index) => content.animate([
{ height: `${contentStarts[index].height}px`, opacity: contentStarts[index].opacity, overflow: "hidden" },
{ height: `${collapsed ? 0 : contentEnds[index]}px`, opacity: collapsed ? 0 : 1, overflow: "hidden" },
{
height: `${contentStarts[index].height}px`,
opacity: contentStarts[index].opacity,
overflow: "hidden",
},
{
height: `${collapsed ? 0 : contentEnds[index]}px`,
opacity: collapsed ? 0 : 1,
overflow: "hidden",
},
], options)),
...cells.map((cell, index) => cell.animate([cellStarts[index], cellEnds[index]], options)),
];

View File

@ -54,7 +54,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
disabled={!row.getCanSelect()}
onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
aria-label={t("common.select_row")}
className='mt-2'
className='mt-2 opacity-0 data-[state=checked]:opacity-100 transition-opacity group-hover/detail:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
/>
),
enableSorting: false,

View File

@ -364,3 +364,47 @@ Inconsistencias observadas, sin cambios fuera del alcance:
- Validación: 28 pruebas de editor/barra y cinco de traducciones; TypeScript sin emisión; ESLint acotado sin errores (tres advertencias `any` previas del editor); Tailwind compilado en `/tmp`; `git diff --check`. Persisten los avisos previos de paginación fuera de `act` y de Browserslist desactualizado.
- Revisión local con Chromium a 1440 y 375 px sobre HTML del harness: badge sobre carpeta, una cabecera para el capítulo con contenido y tres acciones en el vacío. En móvil la tabla mantiene su desplazamiento horizontal. Artefactos temporales `/tmp/uecko-chapter-empty-*`; el fixture temporal se retiró. No es una prueba E2E con API autenticada.
- No se ejecutan base de datos, PDF ni builds de distribución: esta ampliación afecta a la presentación e inserción de detalles del editor, sin cambios en persistencia o informes.
### Ajuste: alineación del arrastre y plegado animado
- `QuoteChaptersDataTable.tsx`: el botón de arrastre del título usa el mismo ancho de 16 px que el de las líneas. Ambos iconos comparten eje horizontal, conservando el orden de columnas y el badge de la carpeta.
- `useChapterCollapse.ts`: transición de altura y opacidad de 200 ms mediante Web Animations API. Se animan el contenido y el espaciado de las celdas antes de ocultar sus filas; incluye cabeceras, detalles, acciones y estado vacío. Los campos permanecen montados; título y subtotal siguen visibles. Se respeta `prefers-reduced-motion` y se pliega inmediatamente cuando no hay soporte para animación.
- Durante el cierre, las filas dejan de ser interactivas mediante `inert` y `aria-hidden`; el arrastre de detalles/destinos vacíos se desactiva desde el inicio. Al invertir el sentido, se cancela la transición anterior y se parte de la posición actual. Al desmontar, se cancelan las animaciones pendientes.
- Dos pruebas nuevas del editor verifican ocultación tras finalizar, reversión sin ocultaciones obsoletas, conservación de valores y movimiento reducido. Validación: 26 pruebas del editor, TypeScript sin emisión, ESLint acotado sin errores y `git diff --check`. Persiste el aviso previo de paginación React/TanStack fuera de `act` en la suite completa; las nuevas pruebas aisladas pasan sin ese aviso.
- Chromium local verifica geometría sobre el HTML actual del harness y ejecuta el hook real: centros de ambos iconos en 117,53125 px; altura del grupo de 222 a 117 px con estados intermedios de 170 y 184,6875 px; recuperación de altura/valores originales y ausencia de animación con movimiento reducido. Artefactos en `/tmp/uecko-chapter-animation-*`; fixture temporal retirado. Esta comprobación no es un E2E con la API autenticada.
- No se ejecutan builds de distribución, PDF ni base de datos: son cambios de presentación del editor, sin cambios de persistencia o informes. La compilación Tailwind se dirige a `/tmp` y conserva el aviso previo de Browserslist desactualizado.
### Corrección: resaltado conjunto de detalle activo y acciones
- `QuoteItemsSortableTableRow.tsx` identifica la fila activa mediante `data-active-item`. `QuoteChaptersDataTable.tsx` aplica el contorno a sus celdas, porque los bordes de las filas no se dibujan en la tabla con `border-separate`.
- Detalle y acciones comparten fondo rojo claro, borde izquierdo rojo de 4 px y contorno exterior fino; se retira el separador intermedio. Las acciones ocupan todo el ancho de la tabla y empiezan junto al borde izquierdo, como en la presentación original. El estilo de selección no se aplica a los capítulos ni a otras líneas.
- Validación: 26 pruebas existentes del editor, TypeScript sin emisión, ESLint acotado sin errores (dos advertencias previas en el componente de fila) y `git diff --check`. Chromium local verifica igualdad de fondos, continuidad del contorno y ausencia de espacio o borde entre detalle y acciones. Muestra en `/tmp/uecko-active-row.png`, generada desde el harness del editor; fixture temporal retirado.
- No se ejecutan base de datos, PDF, builds de distribución ni E2E autenticados: es una corrección de estilos. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
### Ajuste: selección exclusiva de líneas de detalle
- Se elimina el checkbox de los títulos de capítulo, manteniendo su celda vacía para alinear las columnas. La selección se limita a los detalles; el checkbox de cabecera de columnas conserva la selección conjunta de los detalles de ese capítulo.
- Se retira del editor el estado de selección múltiple de capítulos y su bloqueo por selección mixta. Los capítulos se mueven individualmente con todo su contenido y conservan sus botones propios de duplicar/eliminar. La barra actúa sobre los detalles seleccionados.
- Se adaptan las pruebas existentes de duplicación, selección y alineación: 26 pruebas del editor correctas, TypeScript sin emisión, ESLint acotado sin errores y `git diff --check`. Persiste el aviso previo de paginación fuera de `act`. No se ejecutan PDF, base de datos ni E2E: el cambio se limita a la selección en el editor.
### Ajuste: controles visibles al pasar el cursor
- Las filas de detalle muestran arrastre y selección al pasar el cursor por su propia fila, con y sin capítulos. El checkbox marcado permanece visible aunque el cursor salga; al desmarcarlo vuelve a ocultarse. El asa también permanece visible durante el arrastre.
- En el título de capítulo se muestran arrastre, duplicar y eliminar únicamente al pasar por ese título; el hover de los detalles no activa esos botones. Carpeta y badge siguen visibles. Se usan grupos Tailwind con nombre por tipo de fila y opacidad para mantener la alineación.
- Cada control se revela también al recibir foco de teclado. En dispositivos sin hover permanecen visibles para permitir su uso táctil. La transición de opacidad respeta movimiento reducido.
- Cambios en `QuoteChaptersDataTable.tsx`, `QuoteItemsSortableTableRow.tsx`, `QuoteItemsSortableDragHandleCell.tsx` y `useDetailColumns.tsx`. Validación: 27 pruebas existentes de editor/arrastre; ESLint acotado sin errores (dos advertencias previas de la fila); Tailwind compilado en `/tmp`; `git diff --check`. La última excepción de checkbox marcado se comprueba además con CSS real en Chromium.
- Chromium local verifica ocultación, hover por fila, checkbox marcado/desmarcado, foco de teclado y controles táctiles, con y sin capítulos. Para comprobar selectores de escritorio se desactiva solo la regla táctil en el fixture, ya que Chromium sin interfaz informa `hover:none`; la prueba táctil usa la hoja original. Artefactos `/tmp/uecko-row-controls-*`; fixture temporal retirado. No es un E2E autenticado.
- No se ejecutan TypeScript, builds de distribución, PDF ni base de datos: el cambio solo añade clases y no altera tipos, datos o informes. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
### Ajuste: márgenes simétricos de carpeta y eliminar capítulo
- `QuoteChaptersDataTable.tsx`: aumenta el espacio izquierdo de la carpeta y amplía la columna de despliegue de 44 a 56 px para alojarlo sin invadir la columna de arrastre. Conserva tamaños de iconos y botones, badge y alineación de detalles.
- Chromium local mide 20 px tanto entre el borde izquierdo y la carpeta como entre la papelera y el borde derecho, en ambos capítulos a 1440 y 1024 px. El eje de las asas de título/detalle sigue coincidiendo (diferencia 0 px). HTML exportado del harness en `/tmp/uecko-chapter-spacing.html`; fixture temporal retirado.
- Validación acotada: cuatro casos existentes de alineación/orden de columnas, ESLint del componente, compilación Tailwind hacia `/tmp` y `git diff --check`. No se ejecutan TypeScript, PDF, base de datos, builds de distribución ni E2E autenticados por ser un ajuste de espaciado. Persiste el aviso previo de Browserslist desactualizado.
### Ajuste: título alineado con el arrastre de sus conceptos
- El título de capítulo ocupa las columnas posteriores a la carpeta y empieza en el mismo eje vertical que el icono de arrastre de sus detalles. El asa del capítulo se sitúa a la derecha del título, junto a duplicar y eliminar; conserva eventos de arrastre, foco y visibilidad al pasar el cursor.
- `QuoteChaptersDataTable.tsx` ajusta el colspan y espaciado del título. `QuoteChapterEditor.test.tsx` adapta las comprobaciones de estructura y badge a las dos celdas del título. Se mantienen orden de columnas de los detalles y márgenes exteriores de 20 px.
- Validación: cinco casos existentes de alineación, orden y numeración (ejecutados también en el fixture temporal), ESLint sin errores, compilación Tailwind en `/tmp` y `git diff --check`. Chromium local mide diferencia de 0 px entre inicio del texto del título y lado izquierdo del icono de concepto, a 1440 y 1024 px, manteniendo márgenes de carpeta/papelera de 20 px. Fixture temporal retirado; muestra `/tmp/uecko-chapter-title.png`.
- No se ejecutan TypeScript, PDF, base de datos, build de distribución ni E2E autenticado: es un cambio local de disposición, sin cambios de tipos o datos. Persiste el aviso previo de Browserslist desactualizado.