fix(filters): keep the filter dialog responsive on very long option lists

The vessel name list runs to tens of thousands of entries and every one
was mounted as a button when the dialog opened, so the Maritime dialog
took several seconds to appear. Because the dialog centres itself in an
effect after the first paint, that delay also showed it flashing at the
top-left before jumping to the middle.

Mount at most 300 rows until the search narrows the list, say how many
are hidden, and centre in a layout effect so the first paint is already
in place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
C3B2W23
2026-09-11 16:15:30 -07:00
co-authored by Claude Fable 5.1
parent 46f6b7891f
commit 9eee300b97
2 changed files with 77 additions and 4 deletions
@@ -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<string, unknown>) => {
const rest = { ...props };
for (const key of ['initial', 'animate', 'exit', 'transition']) delete rest[key];
return <div {...(rest as React.ComponentProps<'div'>)}>{children}</div>;
},
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const names = Array.from({ length: 1000 }, (_, i) => `VESSEL ${String(i).padStart(4, '0')}`);
function renderModal() {
return render(
<AdvancedFilterModal
title="MARITIME VESSELS"
icon={null}
accentColor="#3B82F6"
accentColorName="blue"
fields={[{ key: 'ship_name', label: 'VESSEL NAME', options: names }]}
activeFilters={{}}
onApply={vi.fn()}
onClose={vi.fn()}
/>,
);
}
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();
});
});
@@ -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<string, string>;
}
// 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<HTMLDivElement>(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({
</div>
) : (
<div className="flex flex-col gap-px">
{filteredOptions.map((option) => {
{visibleOptions.map((option) => {
const isChecked = draft[activeTab]?.has(option);
return (
<button
@@ -383,6 +394,12 @@ export default function AdvancedFilterModal({
</button>
);
})}
{hiddenCount > 0 && (
<div className="text-center py-3 text-[var(--text-muted)] text-[10px] tracking-widest">
SHOWING {visibleOptions.length.toLocaleString()} OF{' '}
{filteredOptions.length.toLocaleString()} SEARCH TO NARROW
</div>
)}
</div>
)}
</div>