diff --git a/frontend/src/__tests__/components/AdvancedFilterModal.test.tsx b/frontend/src/__tests__/components/AdvancedFilterModal.test.tsx new file mode 100644 index 0000000..f85f92f --- /dev/null +++ b/frontend/src/__tests__/components/AdvancedFilterModal.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import AdvancedFilterModal, { MAX_RENDERED_OPTIONS } from '@/components/AdvancedFilterModal'; + +vi.mock('@/lib/motion', () => ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<'div'> & Record) => { + const rest = { ...props }; + for (const key of ['initial', 'animate', 'exit', 'transition']) delete rest[key]; + return
)}>{children}
; + }, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const names = Array.from({ length: 1000 }, (_, i) => `VESSEL ${String(i).padStart(4, '0')}`); + +function renderModal() { + return render( + , + ); +} + +describe('AdvancedFilterModal large option lists', () => { + afterEach(() => cleanup()); + + it('mounts only the head of a long list and says how much is hidden', () => { + renderModal(); + const rows = screen.getAllByRole('button').filter((b) => b.textContent?.startsWith('VESSEL ')); + expect(rows).toHaveLength(MAX_RENDERED_OPTIONS); + expect(screen.getByText('1000 AVAILABLE')).toBeTruthy(); + expect(screen.getByText(/SHOWING 300 OF 1,000/)).toBeTruthy(); + }); + + it('renders the whole list once the search narrows it', () => { + renderModal(); + fireEvent.change(screen.getByPlaceholderText(/Search vessel name/i), { + target: { value: 'VESSEL 09' }, + }); + const rows = screen.getAllByRole('button').filter((b) => b.textContent?.startsWith('VESSEL ')); + expect(rows).toHaveLength(100); + expect(screen.queryByText(/SHOWING/)).toBeNull(); + expect(screen.getByText('100 AVAILABLE')).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/AdvancedFilterModal.tsx b/frontend/src/components/AdvancedFilterModal.tsx index ab20265..39f99df 100644 --- a/frontend/src/components/AdvancedFilterModal.tsx +++ b/frontend/src/components/AdvancedFilterModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import { useState, useMemo, useRef, useCallback, useEffect, useLayoutEffect } from 'react'; import { motion } from '@/lib/motion'; import { Search, X, Check, GripHorizontal } from 'lucide-react'; @@ -11,6 +11,11 @@ interface FilterField { optionLabels?: Record; } +// Option lists come straight from live data (vessel names run to tens of +// thousands); rendering every row as a button makes the dialog take seconds +// to open, so only the head of the list is mounted until the search narrows it. +export const MAX_RENDERED_OPTIONS = 300; + interface AdvancedFilterModalProps { title: string; icon: React.ReactNode; @@ -55,8 +60,8 @@ export default function AdvancedFilterModal({ const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 }); const modalRef = useRef(null); - // Center on mount, clamped so it doesn't overlap the bottom status bar (~48px) - useEffect(() => { + // Center before first paint, clamped so it doesn't overlap the bottom status bar (~48px) + useLayoutEffect(() => { if (modalRef.current) { const rect = modalRef.current.getBoundingClientRect(); const pad = 52; // status bar + small gap @@ -154,6 +159,12 @@ export default function AdvancedFilterModal({ }); }, [activeField, activeTab, searchTerms]); + const visibleOptions = useMemo( + () => filteredOptions.slice(0, MAX_RENDERED_OPTIONS), + [filteredOptions], + ); + const hiddenCount = filteredOptions.length - visibleOptions.length; + // Tailwind color map for dynamic classes const colorMap: Record< string, @@ -355,7 +366,7 @@ export default function AdvancedFilterModal({ ) : (
- {filteredOptions.map((option) => { + {visibleOptions.map((option) => { const isChecked = draft[activeTab]?.has(option); return (
)}