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 */ }
}, []);
const [sortMode, setSortMode] = useState<SortMode>('newest');
const [selectedSupplier, setSelectedSupplier] = useState<string>('all');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
Expand Down
123 changes: 123 additions & 0 deletions src/components/products/ColumnSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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("filters options based on screen width", () => {
// Tablet size
setScreenWidth(800);
const { rerender } = renderSelector(3);

// At 800px, only 3 and 4 columns should be available (minWidth: 0 and 768)
expect(screen.getByRole("radio", { name: "3 colunas" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "4 colunas" })).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "5 colunas" })).not.toBeInTheDocument();

// Mobile size
setScreenWidth(375);
rerender(
<TooltipProvider>
<ColumnSelector value={3} onChange={vi.fn()} />
</TooltipProvider>
);

// Only 1 option available (3 columns) -> should return null
expect(screen.queryByRole("radiogroup")).not.toBeInTheDocument();
});

it("automatically clamps value if screen size shrinks", async () => {
const onChange = vi.fn();
const { rerender } = renderSelector(8, onChange);

// Shrink to tablet (max 4 columns)
setScreenWidth(800);

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

await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(4);
});
});

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);
});
});
49 changes: 28 additions & 21 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",
};
Comment on lines +9 to +15

function GridIcon({ cols, rows = 2 }: { cols: number; rows?: number }) {
const size = 18;
const gap = 2;
Expand Down Expand Up @@ -43,12 +51,6 @@ 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)
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 },
Expand All @@ -61,7 +63,7 @@ function getAvailableOptions(screenWidth: number): ColumnOption[] {
return columnOptions.filter((opt) => screenWidth >= opt.minWidth);
}

function getDefaultColumns(): ColumnCount {
export function getDefaultColumns(): ColumnCount {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
Expand All @@ -83,7 +85,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 +98,6 @@ export function ColumnSelector({ value, onChange, className }: ColumnSelectorPro

const available = getAvailableOptions(screenWidth);

// 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 +106,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 +159,3 @@ export function ColumnSelector({ value, onChange, className }: ColumnSelectorPro
</div>
);
}

export { getDefaultColumns, STORAGE_KEY };
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";

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 */ }
}, []);
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);
}
};
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