Phase 2: Code-Splitting + Virtual Scrolling (Tasks 2.1-2.7)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
@@ -75,6 +76,18 @@ export function ContactList({
|
||||
onSortChange,
|
||||
}: ContactListProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-skip virtualization for small datasets
|
||||
const shouldVirtualize = contacts.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? contacts.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => viewMode === 'cards' ? 120 : 56,
|
||||
overscan: 8,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -105,43 +118,67 @@ export function ContactList({
|
||||
|
||||
// ── List View ──
|
||||
if (viewMode === 'list') {
|
||||
const renderContactItem = (contact: UnifiedContact) => (
|
||||
<li key={contact.id}>
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
|
||||
selectedContactId === contact.id
|
||||
? 'bg-primary-50'
|
||||
: 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 truncate">
|
||||
{getEmail(contact)} · {getCity(contact)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="contact-list-view">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ul className="divide-y divide-secondary-100" role="list">
|
||||
{contacts.map((contact) => (
|
||||
<li key={contact.id}>
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
|
||||
selectedContactId === contact.id
|
||||
? 'bg-primary-50'
|
||||
: 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{shouldVirtualize ? (
|
||||
<ul className="divide-y divide-secondary-100" role="list" style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const contact = contacts[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={contact.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderContactItem(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 truncate">
|
||||
{getEmail(contact)} · {getCity(contact)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-100" role="list">
|
||||
{contacts.map((contact) => renderContactItem(contact))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
@@ -160,11 +197,45 @@ export function ContactList({
|
||||
if (sortBy !== field) return null;
|
||||
return sortOrder === 'asc' ? ' ▲' : ' ▼';
|
||||
};
|
||||
|
||||
const renderContactRow = (contact: UnifiedContact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
|
||||
</td>
|
||||
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
|
||||
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getTags(contact).slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="contact-table-view">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto" style={shouldVirtualize ? { maxHeight: '70vh' } : undefined}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white border-b border-secondary-200">
|
||||
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-secondary-600 w-10">
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
|
||||
@@ -202,38 +273,30 @@ export function ContactList({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-100">
|
||||
{contacts.map((contact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
|
||||
</td>
|
||||
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
|
||||
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getTags(contact).slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{shouldVirtualize ? (
|
||||
<>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const contact = contacts[virtualRow.index];
|
||||
return (
|
||||
<React.Fragment key={contact.id}>
|
||||
{virtualRow.index === 0 && (
|
||||
<tr style={{ height: virtualRow.start }}>
|
||||
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
{renderContactRow(contact)}
|
||||
{virtualRow.index === rowVirtualizer.getVirtualItems().length - 1 && (
|
||||
<tr style={{ height: rowVirtualizer.getTotalSize() - virtualRow.end }}>
|
||||
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
contacts.map((contact) => renderContactRow(contact))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -249,48 +312,74 @@ export function ContactList({
|
||||
}
|
||||
|
||||
// ── Cards View ──
|
||||
const renderContactCard = (contact: UnifiedContact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
|
||||
selectedContactId === contact.id
|
||||
? 'border-primary-300 bg-primary-50'
|
||||
: 'border-secondary-200 bg-white hover:bg-secondary-50',
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
</div>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 space-y-0.5">
|
||||
<div className="truncate">{getEmail(contact)}</div>
|
||||
<div className="truncate">{getPhone(contact)}</div>
|
||||
<div className="truncate">{getCity(contact)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="contact-cards-view">
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{contacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
|
||||
selectedContactId === contact.id
|
||||
? 'border-primary-300 bg-primary-50'
|
||||
: 'border-secondary-200 bg-white hover:bg-secondary-50',
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
|
||||
{shouldVirtualize ? (
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3" style={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const contact = contacts[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={contact.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderContactCard(contact)}
|
||||
</div>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 space-y-0.5">
|
||||
<div className="truncate">{getEmail(contact)}</div>
|
||||
<div className="truncate">{getPhone(contact)}</div>
|
||||
<div className="truncate">{getCity(contact)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{contacts.map((contact) => renderContactCard(contact))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
|
||||
Reference in New Issue
Block a user