diff --git a/src/components/novelties/NoveltyProductGrid.tsx b/src/components/novelties/NoveltyProductGrid.tsx index 8ca40d1d5..649a41edf 100644 --- a/src/components/novelties/NoveltyProductGrid.tsx +++ b/src/components/novelties/NoveltyProductGrid.tsx @@ -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'; @@ -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 { @@ -73,7 +60,13 @@ function getGridGapClass(cols: ColumnCount): string { export function NoveltyProductGrid() { const navigate = useNavigate(); const [viewMode, setViewMode] = useState('grid'); - const [gridColumns, setGridColumns] = useState(getDefaultColumns); + const [gridColumns, setGridColumnsState] = useState(getDefaultColumns); + const setGridColumns = useCallback((cols: ColumnCount) => { + setGridColumnsState(cols); + try { + localStorage.setItem(GRID_COLS_KEY, String(cols)); + } catch { /* empty */ } + }, []); const [sortMode, setSortMode] = useState('newest'); const [selectedSupplier, setSelectedSupplier] = useState('all'); const [selectedCategory, setSelectedCategory] = useState('all'); diff --git a/src/components/products/ColumnSelector.test.tsx b/src/components/products/ColumnSelector.test.tsx new file mode 100644 index 000000000..80b2eeb6a --- /dev/null +++ b/src/components/products/ColumnSelector.test.tsx @@ -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( + + + + ); + }; + + 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(); + }); + }); + + 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( + + + + ); + + 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(); + }); + + // Tablet + setScreenWidth(800); + rerender( + + + + ); + [3, 4, 5, 6, 8].forEach(cols => { + expect(screen.getByRole("radio", { name: `${cols} colunas` })).toBeInTheDocument(); + }); + }); + + 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); + }); +}); diff --git a/src/components/products/ColumnSelector.tsx b/src/components/products/ColumnSelector.tsx index d7e9f6a2e..db6091cd6 100644 --- a/src/components/products/ColumnSelector.tsx +++ b/src/components/products/ColumnSelector.tsx @@ -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 = { + 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; @@ -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) { @@ -83,7 +88,6 @@ interface ColumnSelectorProps { } export function ColumnSelector({ value, onChange, className }: ColumnSelectorProps) { - // Track window width to filter options responsively. const [screenWidth, setScreenWidth] = useState(() => typeof window !== "undefined" ? window.innerWidth : 1600, ); @@ -97,9 +101,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; @@ -108,14 +109,17 @@ 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 ( -
+
{available.map((opt) => { const isActive = value === opt.value; return ( @@ -123,14 +127,22 @@ export function ColumnSelector({ value, onChange, className }: ColumnSelectorPro
); } - -export { getDefaultColumns, STORAGE_KEY }; diff --git a/src/components/products/LayoutPopover.tsx b/src/components/products/LayoutPopover.tsx index cd2493db8..cb51dee2a 100644 --- a/src/components/products/LayoutPopover.tsx +++ b/src/components/products/LayoutPopover.tsx @@ -39,7 +39,7 @@ export const LayoutPopover = React.forwardRef Alterar visualização (grid, lista, tabela) e densidade de colunas - +
{/* View Mode */}
diff --git a/src/components/products/ProductGrid.tsx b/src/components/products/ProductGrid.tsx index b6d6638df..9b33b4416 100644 --- a/src/components/products/ProductGrid.tsx +++ b/src/components/products/ProductGrid.tsx @@ -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 { @@ -138,13 +139,8 @@ function ProductCardWrapper({ ); } -const columnClasses: Record = { - 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, diff --git a/src/components/replenishments/ReplenishmentProductGrid.tsx b/src/components/replenishments/ReplenishmentProductGrid.tsx index 23a916aab..4c5cc485d 100644 --- a/src/components/replenishments/ReplenishmentProductGrid.tsx +++ b/src/components/replenishments/ReplenishmentProductGrid.tsx @@ -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'; @@ -59,7 +59,13 @@ function useLoadingProgress(isLoading: boolean): number { export function ReplenishmentProductGrid() { const navigate = useNavigate(); const [viewMode, setViewMode] = useState('grid'); - const [gridColumns, setGridColumns] = useState(getDefaultColumns); + const [gridColumns, setGridColumnsState] = useState(getDefaultColumns); + const setGridColumns = useCallback((cols: ColumnCount) => { + setGridColumnsState(cols); + try { + localStorage.setItem(GRID_COLS_KEY, String(cols)); + } catch { /* empty */ } + }, []); const [sortMode, setSortMode] = useState('newest'); const [selectedSupplier, setSelectedSupplier] = useState('all'); const [selectedCategory, setSelectedCategory] = useState('all'); diff --git a/src/components/replenishments/grid-layout.ts b/src/components/replenishments/grid-layout.ts index 234edef6c..8d41c9da8 100644 --- a/src/components/replenishments/grid-layout.ts +++ b/src/components/replenishments/grid-layout.ts @@ -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 = { - 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 { diff --git a/src/hooks/products/useCatalogState.ts b/src/hooks/products/useCatalogState.ts index ea5709ffd..cadafe7b3 100644 --- a/src/hooks/products/useCatalogState.ts +++ b/src/hooks/products/useCatalogState.ts @@ -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); diff --git a/src/pages/collections/useCollectionsPageState.ts b/src/pages/collections/useCollectionsPageState.ts index c1dc75f78..1cf9fd931 100644 --- a/src/pages/collections/useCollectionsPageState.ts +++ b/src/pages/collections/useCollectionsPageState.ts @@ -10,7 +10,7 @@ import { useExternalCollectionProductCounts, } from '@/hooks/collections'; import { toast } from 'sonner'; -import { getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector'; +import { STORAGE_KEY as GRID_COLS_KEY, getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector'; import type { ViewMode } from '@/hooks/products'; export function useCollectionsPageState() { @@ -42,7 +42,13 @@ export function useCollectionsPageState() { const [deleteConfirm, setDeleteConfirm] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [viewMode, setViewMode] = useState('grid'); - const [gridColumns, setGridColumns] = useState(getDefaultColumns); + const [gridColumns, setGridColumnsState] = useState(getDefaultColumns); + const setGridColumns = useCallback((cols: ColumnCount) => { + setGridColumnsState(cols); + try { + localStorage.setItem(GRID_COLS_KEY, String(cols)); + } catch { /* empty */ } + }, []); const [selectedCollectionIds, setSelectedCollectionIds] = useState>(new Set()); const [hintDismissed, setHintDismissed] = useState(false); const [formData, setFormData] = useState<{ diff --git a/src/pages/filters/useFiltersPageState.ts b/src/pages/filters/useFiltersPageState.ts index 33af6b5cf..8ca28f9d5 100644 --- a/src/pages/filters/useFiltersPageState.ts +++ b/src/pages/filters/useFiltersPageState.ts @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { type FilterState, defaultFilters } from '@/components/filters/FilterPanel'; -import { getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector'; +import { STORAGE_KEY as GRID_COLS_KEY, getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector'; import { useColorEnrichment } from '@/hooks/products/useColorEnrichment'; import { useProductFuzzySearch } from '@/hooks/products/useProductFuzzySearch'; import { useProductsByCategory } from '@/hooks/products/useProductsByCategory'; @@ -184,20 +184,32 @@ export function useFiltersPageState() { const [activePresetId, setActivePresetId] = useState(); const [viewMode, setViewMode] = useState<'grid' | 'list' | 'table'>('grid'); const [selectionMode, setSelectionMode] = useState(false); - const [gridColumns, setGridColumns] = useState(getDefaultColumns); + const [gridColumns, setGridColumnsState] = useState(getDefaultColumns); + const setGridColumns = useCallback((cols: ColumnCount) => { + setGridColumnsState(cols); + try { + localStorage.setItem(GRID_COLS_KEY, String(cols)); + } catch { /* empty */ } + }, []); // Responsive clamp: force appropriate columns on small screens useEffect(() => { const handleResize = () => { const w = window.innerWidth; - if (w < 768 && gridColumns > 3) { - setGridColumns(3); + 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 [voiceOverlayOpen, setVoiceOverlayOpen] = useState(false); const [commandAction, setCommandAction] = useState(null); const [appliedFilters, setAppliedFilters] = useState< diff --git a/src/pages/products/FavoritesPage.tsx b/src/pages/products/FavoritesPage.tsx index 624a0c91b..91a4f7d88 100644 --- a/src/pages/products/FavoritesPage.tsx +++ b/src/pages/products/FavoritesPage.tsx @@ -14,7 +14,7 @@ import { ProductCard } from '@/components/products/ProductCard'; import { ProductListItem } from '@/components/products/ProductListItem'; import { ProductTableView } from '@/components/products/ProductTableView'; import { LayoutPopover } from '@/components/products/LayoutPopover'; -import { getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector'; +import { STORAGE_KEY as GRID_COLS_KEY, getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector'; import { getGridColsClass, getGridGapClass } from '@/components/replenishments/grid-layout'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -51,7 +51,6 @@ import type { FavoritesSort } from '@/components/favorites/FavoritesSortBar'; type ViewMode = 'grid' | 'list' | 'table'; const VIEW_MODE_KEY = 'favorites-view-mode'; -const GRID_COLS_KEY = 'favorites-grid-cols'; const SELECTED_LIST_KEY = 'favorites-selected-list-id'; const SORT_KEY = 'favorites-sort'; const PRICE_DROP_FILTER_KEY = 'favorites-only-drops';