Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 9 additions & 16 deletions src/components/novelties/NoveltyProductGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { useNoveltiesSelectionMode, useNoveltiesWithDetails } from '@/hooks/products';
import { ProductCardSkeleton } from '@/components/products/ProductCardSkeleton';
import { LayoutPopover } from '@/components/products/LayoutPopover';
import { getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector';
import { STORAGE_KEY as GRID_COLS_KEY, getDefaultColumns, type ColumnCount, COLUMN_CLASSES } from '@/components/products/ColumnSelector';
import { BulkActionBar } from '@/components/products/BulkActionBar';
import { BulkVariantWizard } from '@/components/catalog/BulkVariantWizard';
import { BulkAddToCartModal } from '@/components/catalog/BulkAddToCartModal';
Expand All @@ -48,20 +48,7 @@ type SortMode =
| 'best-seller-promo';

function getGridColsClass(cols: ColumnCount): string {
switch (cols) {
case 3:
return 'grid-cols-2 sm:grid-cols-3';
case 4:
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4';
case 5:
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5';
case 6:
return 'grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
case 8:
return 'grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8';
default:
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5';
}
return COLUMN_CLASSES[cols] || COLUMN_CLASSES[5];
}

function getGridGapClass(cols: ColumnCount): string {
Expand All @@ -73,7 +60,13 @@ function getGridGapClass(cols: ColumnCount): string {
export function NoveltyProductGrid() {
const navigate = useNavigate();
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [gridColumns, setGridColumns] = useState<ColumnCount>(getDefaultColumns);
const [gridColumns, setGridColumnsState] = useState<ColumnCount>(getDefaultColumns);
const setGridColumns = useCallback((cols: ColumnCount) => {
setGridColumnsState(cols);
try {
localStorage.setItem(GRID_COLS_KEY, String(cols));
} catch { /* empty */ }
}, []);
Comment on lines 60 to +69
const [sortMode, setSortMode] = useState<SortMode>('newest');
const [selectedSupplier, setSelectedSupplier] = useState<string>('all');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
Expand Down
103 changes: 103 additions & 0 deletions src/components/products/ColumnSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ColumnSelector, STORAGE_KEY } from "./ColumnSelector";
import { TooltipProvider } from "@/components/ui/tooltip";

// Mock screen width
const setScreenWidth = (width: number) => {
act(() => {
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: width,
});
window.dispatchEvent(new Event('resize'));
});
};

describe("ColumnSelector", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
setScreenWidth(1600); // Desktop size with all options
});

const renderSelector = (value: any = 5, onChange = vi.fn()) => {
return render(
<TooltipProvider>
<ColumnSelector value={value} onChange={onChange} />
</TooltipProvider>
);
};

it("renders all column options on desktop with correct accessibility roles", () => {
renderSelector();
expect(screen.getByRole("radiogroup", { name: /Número de colunas/i })).toBeInTheDocument();

const options = [3, 4, 5, 6, 8];
options.forEach(cols => {
expect(screen.getByRole("radio", { name: `${cols} colunas` })).toBeInTheDocument();
});
Comment on lines +37 to +40
});

it("calls onChange and updates localStorage when an option is clicked", () => {
const onChange = vi.fn();
renderSelector(5, onChange);

const button3 = screen.getByRole("radio", { name: "3 colunas" });
fireEvent.click(button3);

expect(onChange).toHaveBeenCalledWith(3);
expect(localStorage.getItem(STORAGE_KEY)).toBe("3");
});

it("persists selection in localStorage across re-renders with aria-checked", () => {
const { rerender } = renderSelector(5);
const button8 = screen.getByRole("radio", { name: "8 colunas" });

fireEvent.click(button8);
expect(localStorage.getItem(STORAGE_KEY)).toBe("8");

rerender(
<TooltipProvider>
<ColumnSelector value={8} onChange={vi.fn()} />
</TooltipProvider>
);

expect(screen.getByRole("radio", { name: "8 colunas" })).toHaveAttribute("aria-checked", "true");
});

it("always shows all 5 column options regardless of screen width", () => {
// Mobile
setScreenWidth(375);
const { rerender } = renderSelector(3);
[3, 4, 5, 6, 8].forEach(cols => {
expect(screen.getByRole("radio", { name: `${cols} colunas` })).toBeInTheDocument();
});
Comment on lines +74 to +76

// Tablet
setScreenWidth(800);
rerender(
<TooltipProvider>
<ColumnSelector value={3} onChange={vi.fn()} />
</TooltipProvider>
);
[3, 4, 5, 6, 8].forEach(cols => {
expect(screen.getByRole("radio", { name: `${cols} colunas` })).toBeInTheDocument();
});
Comment on lines +85 to +87
});

it("supports keyboard navigation with Enter/Space", () => {
const onChange = vi.fn();
renderSelector(5, onChange);

const button3 = screen.getByRole("radio", { name: "3 colunas" });

fireEvent.focus(button3);
fireEvent.keyDown(button3, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(3);

fireEvent.keyDown(button3, { key: " " });
expect(onChange).toHaveBeenCalledTimes(2);
});
});
64 changes: 37 additions & 27 deletions src/components/products/ColumnSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import { useEffect, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

const STORAGE_KEY = "product-grid-columns";
export const STORAGE_KEY = "product-grid-columns";

export type ColumnCount = 3 | 4 | 5 | 6 | 8;

export const COLUMN_CLASSES: Record<ColumnCount, string> = {
3: "grid-cols-3",
4: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4",
5: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
6: "grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6",
8: "grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8",
};

function GridIcon({ cols, rows = 2 }: { cols: number; rows?: number }) {
const size = 18;
const gap = 2;
Expand Down Expand Up @@ -43,25 +51,22 @@ interface ColumnOption {
minWidth: number;
}

// Breakpoints alinhados com responsividade do grid de produtos:
// - 3 colunas: sempre disponível (mobile-first)
// - 4 colunas: ≥768px (md tailwind)
// - 5 colunas: ≥1024px (lg tailwind)
// - 6 colunas: ≥1280px (xl tailwind)
// - 8 colunas: ≥1536px (2xl tailwind)
// Todas as 5 opções sempre disponíveis. O grid se adapta responsivamente
// via Tailwind classes (COLUMN_CLASSES), então não há necessidade de esconder
// opções por largura de tela.
const columnOptions: ColumnOption[] = [
{ value: 3, label: "3 colunas", cols: 3, rows: 2, minWidth: 0 },
{ value: 4, label: "4 colunas", cols: 4, rows: 2, minWidth: 768 },
{ value: 5, label: "5 colunas", cols: 5, rows: 2, minWidth: 1024 },
{ value: 6, label: "6 colunas", cols: 3, rows: 3, minWidth: 1280 },
{ value: 8, label: "8 colunas", cols: 4, rows: 3, minWidth: 1536 },
{ value: 4, label: "4 colunas", cols: 4, rows: 2, minWidth: 0 },
{ value: 5, label: "5 colunas", cols: 5, rows: 2, minWidth: 0 },
{ value: 6, label: "6 colunas", cols: 3, rows: 3, minWidth: 0 },
{ value: 8, label: "8 colunas", cols: 4, rows: 3, minWidth: 0 },
];

function getAvailableOptions(screenWidth: number): ColumnOption[] {
return columnOptions.filter((opt) => screenWidth >= opt.minWidth);
function getAvailableOptions(_screenWidth: number): ColumnOption[] {
return columnOptions;
}

function getDefaultColumns(): ColumnCount {
export function getDefaultColumns(): ColumnCount {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
Expand All @@ -83,7 +88,6 @@ interface ColumnSelectorProps {
}

export function ColumnSelector({ value, onChange, className }: ColumnSelectorProps) {
// Track window width to filter options responsively.
const [screenWidth, setScreenWidth] = useState<number>(() =>
typeof window !== "undefined" ? window.innerWidth : 1600,
);
Expand All @@ -97,9 +101,6 @@ export function ColumnSelector({ value, onChange, className }: ColumnSelectorPro

const available = getAvailableOptions(screenWidth);

Comment on lines 90 to 103
// Clamping: se o valor controlado ultrapassa o máximo disponível para a
// largura atual, dispara onChange para o maior valor permitido. Mantém
// a UI consistente quando a tela encolhe ou o valor vem maior do esperado.
useEffect(() => {
if (available.length === 0) return;
const maxAvailable = available[available.length - 1].value;
Expand All @@ -108,29 +109,40 @@ export function ColumnSelector({ value, onChange, className }: ColumnSelectorPro
}
}, [value, available, onChange]);

// Quando só sobra 1 opção (ou nenhuma), o seletor não tem utilidade.
if (available.length <= 1) return null;

return (
<div className={cn(
"inline-flex items-center gap-0.5 p-1 rounded-xl bg-muted/60 border border-border/40",
className
)}>
<div
role="radiogroup"
aria-label="Número de colunas"
className={cn(
"inline-flex items-center gap-0.5 p-1 rounded-xl bg-muted/60 border border-border/40",
className
)}
>
{available.map((opt) => {
const isActive = value === opt.value;
return (
<Tooltip key={opt.value}>
<TooltipTrigger asChild>
<button
type="button"
role="radio"
aria-label={opt.label}
aria-pressed={isActive}
aria-checked={isActive}
className={cn(
"relative flex items-center justify-center h-9 w-9 rounded-lg transition-colors duration-150 cursor-pointer",
"relative flex items-center justify-center h-9 w-9 rounded-lg transition-all duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ring-offset-background",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange(opt.value);
try { localStorage.setItem(STORAGE_KEY, String(opt.value)); } catch { /* empty */ }
}
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
Expand All @@ -150,5 +162,3 @@ export function ColumnSelector({ value, onChange, className }: ColumnSelectorPro
</div>
);
}

export { getDefaultColumns, STORAGE_KEY };
2 changes: 1 addition & 1 deletion src/components/products/LayoutPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const LayoutPopover = React.forwardRef<HTMLDivElement, LayoutPopoverProps
</TooltipTrigger>
<TooltipContent>Alterar visualização (grid, lista, tabela) e densidade de colunas</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-60 p-4" sideOffset={8}>
<PopoverContent align="end" className="w-64 p-4" sideOffset={8}>
<div className="space-y-4">
{/* View Mode */}
<div>
Expand Down
10 changes: 3 additions & 7 deletions src/components/products/ProductGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useEffect, useState, useRef } from "react";
import { useReducedMotion } from "@/hooks/ui/useReducedMotion";
import { SelectionCheckbox } from "@/components/common/SelectionCheckbox";
import { cn } from "@/lib/utils";
import { COLUMN_CLASSES, type ColumnCount } from "./ColumnSelector";
import { ProductCardSkeleton } from "./ProductCardSkeleton";
Comment on lines +8 to 9

export interface ProductGridProps {
Expand Down Expand Up @@ -138,13 +139,8 @@ function ProductCardWrapper({
);
}

const columnClasses: Record<number, string> = {
3: "grid-cols-2 sm:grid-cols-3",
4: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5",
6: "grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6",
8: "grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8",
};
// Usando as classes centralizadas para garantir consistência em todos os grids do sistema.
const columnClasses = COLUMN_CLASSES;

export function ProductGrid({
products,
Expand Down
10 changes: 8 additions & 2 deletions src/components/replenishments/ReplenishmentProductGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useReplenishmentsSelectionMode,
useReplenishmentsWithDetails,
} from '@/hooks/products';
import { getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector';
import { STORAGE_KEY as GRID_COLS_KEY, getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector';
import { BulkActionBar } from '@/components/products/BulkActionBar';
import { BulkVariantWizard } from '@/components/catalog/BulkVariantWizard';
import { BulkAddToCartModal } from '@/components/catalog/BulkAddToCartModal';
Expand Down Expand Up @@ -59,7 +59,13 @@ function useLoadingProgress(isLoading: boolean): number {
export function ReplenishmentProductGrid() {
const navigate = useNavigate();
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [gridColumns, setGridColumns] = useState<ColumnCount>(getDefaultColumns);
const [gridColumns, setGridColumnsState] = useState<ColumnCount>(getDefaultColumns);
const setGridColumns = useCallback((cols: ColumnCount) => {
setGridColumnsState(cols);
try {
localStorage.setItem(GRID_COLS_KEY, String(cols));
} catch { /* empty */ }
}, []);
Comment on lines 59 to +68
const [sortMode, setSortMode] = useState<SortMode>('newest');
const [selectedSupplier, setSelectedSupplier] = useState('all');
const [selectedCategory, setSelectedCategory] = useState('all');
Expand Down
12 changes: 2 additions & 10 deletions src/components/replenishments/grid-layout.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import type { ColumnCount } from "@/components/products/ColumnSelector";
import { COLUMN_CLASSES, type ColumnCount } from "@/components/products/ColumnSelector";

export function colsToNum(cols: ColumnCount): number {
return typeof cols === "number" ? cols : 5;
}

export function getGridColsClass(cols: ColumnCount): string {
const map: Record<ColumnCount, string> = {
3: "grid-cols-2 sm:grid-cols-3",
4: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5",
6: "grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6",
8: "grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8",
};

return map[cols] ?? map[5];
return COLUMN_CLASSES[cols] ?? COLUMN_CLASSES[5];
}

export function getGridGapClass(cols: ColumnCount): string {
Expand Down
17 changes: 11 additions & 6 deletions src/hooks/products/useCatalogState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,25 @@ export function useCatalogState() {
});
}, []);

// Responsive clamp
// Responsive clamp: garante que o número de colunas não ultrapasse o disponível
// para a largura atual da tela, mantendo a consistência visual.
useEffect(() => {
const handleResize = () => {
const w = window.innerWidth;
if (w < 640 && gridColumns > 1) {
setGridColumnsState(3 as ColumnCount);
} else if (w >= 640 && w < 768 && gridColumns > 2) {
setGridColumnsState(3 as ColumnCount);
let maxCols: ColumnCount = 3;
if (w >= 1536) maxCols = 8;
else if (w >= 1280) maxCols = 6;
else if (w >= 1024) maxCols = 5;
else if (w >= 768) maxCols = 4;

if (gridColumns > maxCols) {
setGridColumns(maxCols);
}
Comment on lines +110 to 123
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [gridColumns]);
}, [gridColumns, setGridColumns]);

const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(searchQueryFromUrl);
Expand Down
Loading