mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-14 21:25:29 +02:00
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:
co-authored by
Claude Fable 5.1
parent
46f6b7891f
commit
9eee300b97
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user