Phase 2: Code-Splitting + Virtual Scrolling (Tasks 2.1-2.7)
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Mail list component with pagination.
|
||||
* Mail list component with pagination and virtual scrolling.
|
||||
* Shows list of mails in selected folder with seen/flagged indicators.
|
||||
*/
|
||||
|
||||
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 type { Mail } from '@/api/mail';
|
||||
import { decodeMimeHeader } from '@/api/mail';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
@@ -53,6 +54,18 @@ export function MailList({
|
||||
const { t } = useTranslation();
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
|
||||
const scrollRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Auto-skip virtualization for small datasets
|
||||
const shouldVirtualize = mails.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? mails.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 80, // approx mail item height
|
||||
overscan: 8,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
const handleSortFieldChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newSortBy = e.target.value as 'date' | 'from' | 'subject';
|
||||
@@ -83,6 +96,75 @@ export function MailList({
|
||||
);
|
||||
}
|
||||
|
||||
const renderMailItem = (mail: Mail) => (
|
||||
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center pt-3 pl-3 md:pl-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMailIds.has(mail.id)}
|
||||
onChange={() => onToggleSelect(mail.id)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('mail.selectMail')}
|
||||
data-testid={`mail-checkbox-${mail.id}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelectMail(mail)}
|
||||
className={clsx(
|
||||
'w-full text-left px-1 py-2 md:px-2 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
mail.id === selectedMailId && 'bg-primary-50',
|
||||
!mail.is_seen && 'font-semibold',
|
||||
)}
|
||||
aria-selected={mail.id === selectedMailId}
|
||||
data-testid={`mail-item-${mail.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{!mail.is_seen && (
|
||||
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
|
||||
)}
|
||||
{mail.is_flagged && (
|
||||
<span className="flex-shrink-0" aria-label={t('mail.flagged')}>
|
||||
{mail.flag_type === 'star' && <span className="text-sm">⭐</span>}
|
||||
{mail.flag_type === 'flag' && <span className="text-sm">🚩</span>}
|
||||
{mail.flag_type === 'bookmark' && <span className="text-sm">🔖</span>}
|
||||
{mail.flag_type === 'important' && <span className="text-sm">❗</span>}
|
||||
{mail.flag_type === 'question' && <span className="text-sm">❓</span>}
|
||||
{(!mail.flag_type || mail.flag_type === '') && (
|
||||
<Star className="w-4 h-4 text-warning-500" fill="currentColor" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{mail.has_attachments && (
|
||||
<Paperclip className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
|
||||
{decodeMimeHeader(mail.from_name) || mail.from_address}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
||||
</div>
|
||||
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
|
||||
</p>
|
||||
{mail.labels && mail.labels.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{mail.labels.map((label) => (
|
||||
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
|
||||
{/* Select-all + Sort header in one row */}
|
||||
@@ -133,76 +215,45 @@ export function MailList({
|
||||
)}
|
||||
</div>
|
||||
{/* Mail list — scrolls, pagination stays fixed */}
|
||||
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
|
||||
{mails.map((mail) => (
|
||||
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center pt-3 pl-3 md:pl-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMailIds.has(mail.id)}
|
||||
onChange={() => onToggleSelect(mail.id)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('mail.selectMail')}
|
||||
data-testid={`mail-checkbox-${mail.id}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelectMail(mail)}
|
||||
className={clsx(
|
||||
'w-full text-left px-1 py-2 md:px-2 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
mail.id === selectedMailId && 'bg-primary-50',
|
||||
!mail.is_seen && 'font-semibold',
|
||||
)}
|
||||
aria-selected={mail.id === selectedMailId}
|
||||
data-testid={`mail-item-${mail.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{!mail.is_seen && (
|
||||
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
|
||||
)}
|
||||
{mail.is_flagged && (
|
||||
<span className="flex-shrink-0" aria-label={t('mail.flagged')}>
|
||||
{mail.flag_type === 'star' && <span className="text-sm">⭐</span>}
|
||||
{mail.flag_type === 'flag' && <span className="text-sm">🚩</span>}
|
||||
{mail.flag_type === 'bookmark' && <span className="text-sm">🔖</span>}
|
||||
{mail.flag_type === 'important' && <span className="text-sm">❗</span>}
|
||||
{mail.flag_type === 'question' && <span className="text-sm">❓</span>}
|
||||
{(!mail.flag_type || mail.flag_type === '') && (
|
||||
<Star className="w-4 h-4 text-warning-500" fill="currentColor" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{mail.has_attachments && (
|
||||
<Paperclip className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
|
||||
{decodeMimeHeader(mail.from_name) || mail.from_address}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
||||
</div>
|
||||
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
|
||||
</p>
|
||||
{mail.labels && mail.labels.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{mail.labels.map((label) => (
|
||||
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldVirtualize ? (
|
||||
<ul
|
||||
ref={scrollRef}
|
||||
className="divide-y divide-secondary-100 flex-1 overflow-y-auto"
|
||||
role="list"
|
||||
style={{ contain: 'strict' }}
|
||||
>
|
||||
<li
|
||||
key="__virtual-spacer"
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const mail = mails[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={mail.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderMailItem(mail)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
|
||||
{mails.map((mail) => renderMailItem(mail))}
|
||||
</ul>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex-shrink-0">
|
||||
<Pagination
|
||||
|
||||
Reference in New Issue
Block a user