refactor(emailclient): extract MailboxHeader, MailboxSidebar, EmailDetail components

Moves inline JSX from EmailClientContent into proper FakeMUI email
components in components/fakemui/email/layout/. All styling uses M3
CSS custom properties (--mat-sys-*) from the FakeMUI token system.

New components:
- MailboxHeader: top bar with search, avatar, settings
- MailboxSidebar: compose button + folder navigation
- EmailDetail: reading pane with toolbar, header, body, reply bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 14:52:58 +00:00
parent 8d007afd24
commit 8c21105d72
7 changed files with 907 additions and 476 deletions

View File

@@ -57,6 +57,13 @@ export {
export {
MailboxLayout,
type MailboxLayoutProps,
MailboxHeader,
type MailboxHeaderProps,
MailboxSidebar,
type MailboxSidebarProps,
EmailDetail,
type EmailDetailProps,
type EmailDetailEmail,
ComposerLayout,
type ComposerLayoutProps,
SettingsLayout,

View File

@@ -0,0 +1,109 @@
import React from 'react'
import { Box, BoxProps, Button, IconButton } from '../..'
import { useAccessible } from '../../../../hooks/useAccessible'
import { EmailHeader } from '../data-display'
export interface EmailDetailEmail {
id: string
from: string
to: string[]
cc?: string[]
subject: string
body: string
receivedAt: number
isStarred: boolean
}
export interface EmailDetailProps extends BoxProps {
email: EmailDetailEmail
onClose?: () => void
onArchive?: () => void
onDelete?: () => void
onReply?: () => void
onForward?: () => void
onToggleStar?: (starred: boolean) => void
testId?: string
}
export const EmailDetail = ({
email,
onClose,
onArchive,
onDelete,
onReply,
onForward,
onToggleStar,
testId: customTestId,
...props
}: EmailDetailProps) => {
const accessible = useAccessible({
feature: 'email',
component: 'email-detail',
identifier: customTestId || 'detail'
})
return (
<Box className="email-detail" {...accessible} {...props}>
<Box className="email-detail-toolbar">
{onClose && (
<IconButton
aria-label="Back to list"
className="email-detail-back"
onClick={onClose}
>
<span></span>
</IconButton>
)}
<Box className="email-detail-actions">
{onArchive && (
<IconButton aria-label="Archive" title="Archive" onClick={onArchive}>
<span>📥</span>
</IconButton>
)}
{onDelete && (
<IconButton aria-label="Delete" title="Delete" onClick={onDelete}>
<span>🗑</span>
</IconButton>
)}
{onReply && (
<IconButton aria-label="Reply" title="Reply" onClick={onReply}>
<span></span>
</IconButton>
)}
{onForward && (
<IconButton aria-label="Forward" title="Forward" onClick={onForward}>
<span></span>
</IconButton>
)}
</Box>
</Box>
<EmailHeader
from={email.from}
to={email.to}
cc={email.cc}
subject={email.subject}
receivedAt={email.receivedAt}
isStarred={email.isStarred}
onToggleStar={onToggleStar}
/>
<Box className="email-detail-body">
{email.body}
</Box>
<Box className="email-detail-reply-bar">
{onReply && (
<Button variant="outlined" className="email-detail-reply-btn" onClick={onReply}>
Reply
</Button>
)}
{onForward && (
<Button variant="outlined" className="email-detail-reply-btn" onClick={onForward}>
Forward
</Button>
)}
</Box>
</Box>
)
}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { Box, BoxProps, IconButton, Typography } from '../..'
import { useAccessible } from '../../../../hooks/useAccessible'
export interface MailboxHeaderProps extends BoxProps {
appName?: string
appIcon?: string
searchQuery?: string
onSearchChange?: (query: string) => void
searchPlaceholder?: string
avatarLabel?: string
onSettingsClick?: () => void
testId?: string
}
export const MailboxHeader = ({
appName = 'MetaMail',
appIcon = '📧',
searchQuery = '',
onSearchChange,
searchPlaceholder = 'Search mail',
avatarLabel = 'U',
onSettingsClick,
testId: customTestId,
...props
}: MailboxHeaderProps) => {
const accessible = useAccessible({
feature: 'email',
component: 'mailbox-header',
identifier: customTestId || 'header'
})
return (
<Box className="mailbox-header-bar" {...accessible} {...props}>
<Box className="mailbox-header-brand">
<span className="mailbox-header-icon">{appIcon}</span>
<Typography variant="h6" className="mailbox-header-title">
{appName}
</Typography>
</Box>
<Box className="mailbox-header-search">
<input
type="search"
className="mailbox-search-input"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={e => onSearchChange?.(e.target.value)}
aria-label={searchPlaceholder}
/>
</Box>
<Box className="mailbox-header-actions">
{onSettingsClick && (
<IconButton aria-label="Settings" title="Settings" onClick={onSettingsClick}>
<span className="mailbox-header-action-icon"></span>
</IconButton>
)}
<Box className="mailbox-header-avatar" aria-label="Account">
{avatarLabel}
</Box>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { Box, BoxProps } from '../..'
import { useAccessible } from '../../../../hooks/useAccessible'
import { FolderNavigation, type FolderNavigationItem } from '../navigation'
export interface MailboxSidebarProps extends BoxProps {
folders: FolderNavigationItem[]
onNavigate?: (folderId: string) => void
onCompose?: () => void
composeLabel?: string
testId?: string
}
export const MailboxSidebar = ({
folders,
onNavigate,
onCompose,
composeLabel = 'Compose',
testId: customTestId,
...props
}: MailboxSidebarProps) => {
const accessible = useAccessible({
feature: 'email',
component: 'mailbox-sidebar',
identifier: customTestId || 'sidebar'
})
return (
<Box className="mailbox-sidebar-content" {...accessible} {...props}>
{onCompose && (
<Box className="mailbox-sidebar-compose">
<button className="compose-btn" onClick={onCompose}>
{composeLabel}
</button>
</Box>
)}
<FolderNavigation items={folders} onNavigate={onNavigate} />
</Box>
)
}

View File

@@ -1,3 +1,6 @@
export { MailboxLayout, type MailboxLayoutProps } from './MailboxLayout'
export { MailboxHeader, type MailboxHeaderProps } from './MailboxHeader'
export { MailboxSidebar, type MailboxSidebarProps } from './MailboxSidebar'
export { EmailDetail, type EmailDetailProps, type EmailDetailEmail } from './EmailDetail'
export { ComposerLayout, type ComposerLayoutProps } from './ComposerLayout'
export { SettingsLayout, type SettingsLayoutProps, type SettingsSection } from './SettingsLayout'

View File

@@ -3,18 +3,14 @@
import React, { useState, useCallback } from 'react'
import {
MailboxLayout,
FolderNavigation,
MailboxHeader,
MailboxSidebar,
EmailDetail,
type FolderNavigationItem,
ThreadList,
EmailHeader,
ComposeWindow,
} from '@metabuilder/fakemui/email'
import {
Box,
Typography,
IconButton,
Button,
} from '@metabuilder/fakemui'
import { Box, Typography } from '@metabuilder/fakemui'
// ─────────────────────────────────────────────────────────────────────────────
// Demo data — replace with useMessages/useMailboxes hooks when IMAP backend is ready
@@ -133,7 +129,6 @@ export default function EmailClientContent() {
if (activeFolder === 'drafts') return false
if (activeFolder === 'spam') return false
if (activeFolder === 'trash') return false
// inbox
if (searchQuery) {
const q = searchQuery.toLowerCase()
return (
@@ -147,7 +142,6 @@ export default function EmailClientContent() {
const handleSelectEmail = useCallback((emailId: string) => {
setSelectedEmailId(emailId)
// Mark as read
setEmails(prev => prev.map(e => e.id === emailId ? { ...e, isRead: true } : e))
}, [])
@@ -162,6 +156,7 @@ export default function EmailClientContent() {
const handleSend = useCallback((data: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; body: string }) => {
const newEmail = {
id: String(Date.now()),
testId: String(Date.now()),
from: 'You',
to: data.to,
subject: data.subject,
@@ -180,81 +175,40 @@ export default function EmailClientContent() {
setSelectedEmailId(null)
}, [])
// ── Sidebar ──────────────────────────────────────────────────────────────
const sidebar = (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ padding: '16px' }}>
<Button
variant="contained"
onClick={() => setShowCompose(true)}
sx={{ width: '100%', borderRadius: '24px', padding: '12px 24px', fontSize: '0.9375rem', fontWeight: 500, textTransform: 'none', boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
Compose
</Button>
</Box>
<FolderNavigation items={folders} onNavigate={handleNavigateFolder} />
</Box>
)
const unreadCount = filteredEmails.filter(e => !e.isRead).length
// ── Header ───────────────────────────────────────────────────────────────
const header = (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '12px', width: '100%', padding: '0 8px' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '24px' }}>📧</span>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.125rem' }}>
MetaMail
</Typography>
</Box>
<Box sx={{ flex: 1, maxWidth: '680px', margin: '0 auto' }}>
<input
type="search"
placeholder="Search mail"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '10px 16px',
borderRadius: '24px',
border: 'none',
backgroundColor: '#f1f3f4',
fontSize: '0.9375rem',
outline: 'none',
}}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<IconButton aria-label="Settings" title="Settings">
<span style={{ fontSize: '20px' }}></span>
</IconButton>
<Box sx={{
width: '32px', height: '32px', borderRadius: '50%',
backgroundColor: '#1976d2', color: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.875rem', fontWeight: 600, cursor: 'pointer',
}}>
U
</Box>
</Box>
</Box>
<MailboxHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSettingsClick={() => {}}
/>
)
const sidebar = (
<MailboxSidebar
folders={folders}
onNavigate={handleNavigateFolder}
onCompose={() => setShowCompose(true)}
/>
)
// ── Main (thread list) ───────────────────────────────────────────────────
const main = (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ padding: '8px 16px', borderBottom: '1px solid #e0e0e0', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2" sx={{ color: '#5f6368', fontWeight: 500, textTransform: 'capitalize' }}>
<Box className="mailbox-thread-panel">
<Box className="mailbox-thread-toolbar">
<Typography variant="body2" className="mailbox-thread-folder-label">
{activeFolder} {filteredEmails.length > 0 && `(${filteredEmails.length})`}
</Typography>
<Typography variant="caption" sx={{ color: '#5f6368' }}>
{filteredEmails.filter(e => !e.isRead).length} unread
<Typography variant="caption" className="mailbox-thread-unread-label">
{unreadCount} unread
</Typography>
</Box>
{filteredEmails.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, color: '#5f6368', gap: '8px' }}>
<span style={{ fontSize: '48px', opacity: 0.5 }}>
<Box className="mailbox-empty-state">
<span className="mailbox-empty-icon">
{activeFolder === 'starred' ? '⭐' : activeFolder === 'trash' ? '🗑️' : '📭'}
</span>
<Typography variant="body1">
<Typography variant="body2">
{activeFolder === 'inbox' && searchQuery ? 'No results found' : `No messages in ${activeFolder}`}
</Typography>
</Box>
@@ -270,53 +224,16 @@ export default function EmailClientContent() {
</Box>
)
// ── Detail (selected email) ──────────────────────────────────────────────
const detail = selectedEmail ? (
<Box sx={{ height: '100%', overflow: 'auto', padding: '16px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
<button
onClick={() => setSelectedEmailId(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.25rem', padding: '4px' }}
aria-label="Back to list"
>
</button>
<Box sx={{ display: 'flex', gap: '4px' }}>
<IconButton aria-label="Archive" title="Archive">
<span style={{ fontSize: '18px' }}>📥</span>
</IconButton>
<IconButton aria-label="Delete" title="Delete">
<span style={{ fontSize: '18px' }}>🗑</span>
</IconButton>
<IconButton aria-label="Reply" title="Reply">
<span style={{ fontSize: '18px' }}></span>
</IconButton>
<IconButton aria-label="Forward" title="Forward">
<span style={{ fontSize: '18px' }}></span>
</IconButton>
</Box>
</Box>
<EmailHeader
from={selectedEmail.from}
to={selectedEmail.to || ['you@metabuilder.io']}
cc={selectedEmail.cc}
subject={selectedEmail.subject}
receivedAt={selectedEmail.receivedAt}
isStarred={selectedEmail.isStarred}
onToggleStar={(starred) => handleToggleStar(selectedEmail.id, starred)}
/>
<Box sx={{ marginTop: '24px', fontSize: '0.9375rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', color: '#3c4043' }}>
{selectedEmail.body}
</Box>
<Box sx={{ marginTop: '32px', borderTop: '1px solid #e0e0e0', paddingTop: '16px' }}>
<Button variant="outlined" onClick={() => setShowCompose(true)} sx={{ borderRadius: '20px', textTransform: 'none' }}>
Reply
</Button>
<Button variant="outlined" onClick={() => setShowCompose(true)} sx={{ borderRadius: '20px', textTransform: 'none', marginLeft: '8px' }}>
Forward
</Button>
</Box>
</Box>
<EmailDetail
email={selectedEmail}
onClose={() => setSelectedEmailId(null)}
onArchive={() => {}}
onDelete={() => {}}
onReply={() => setShowCompose(true)}
onForward={() => setShowCompose(true)}
onToggleStar={(starred) => handleToggleStar(selectedEmail.id, starred)}
/>
) : undefined
return (

File diff suppressed because it is too large Load Diff