mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-14 21:25:29 +02:00
Merge remote-tracking branch 'origin/pr/561' into codex/pr-561-562-559
This commit is contained in:
@@ -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