refactor(fakemui): Migrate email components from email-wip to email directory

- Move 22 email components from email-wip/ to email/ (production ready)
- Components organized by category: atoms (3), inputs (3), surfaces (4), data-display (4), feedback (2), layout (3), navigation (2)
- Total 29 files migrated including category index files
- Update index files for data-display, inputs, surfaces categories
- Restore I18nNavigation.ts and hooks.ts (actively used by postgres module)
- email-wip directory removed as part of migration

Files migrated:
- Atoms: AttachmentIcon, MarkAsReadCheckbox, StarButton
- Inputs: BodyEditor, EmailAddressInput, RecipientInput
- Surfaces: ComposeWindow, EmailCard, MessageThread, SignatureCard
- Data-Display: AttachmentList, EmailHeader, FolderTree, ThreadList
- Feedback: SyncProgress, SyncStatusBadge
- Layout: ComposerLayout, MailboxLayout, SettingsLayout
- Navigation: AccountTabs, FolderNavigation

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 20:33:36 +00:00
parent 37efdc408f
commit c9fb2ce8e8
32 changed files with 1390 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
export { Avatar, AvatarGroup } from './Avatar'
export { Badge } from './Badge'
export { Chip } from './Chip'
export { Divider } from './Divider'
export { Icon } from './Icon'
export { List, ListItem, ListItemButton, ListItemIcon, ListItemText, ListItemAvatar, ListSubheader } from './List'
export { Table, TableHead, TableBody, TableFooter, TableRow, TableCell, TableContainer, TablePagination, TableSortLabel } from './Table'
export { Tooltip } from './Tooltip'
export { TreeView } from './TreeView'
export { Typography } from './Typography'
export { Markdown } from './Markdown'
export { Separator } from './Separator'

View File

@@ -0,0 +1,47 @@
import React, { forwardRef } from 'react'
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface AttachmentIconProps extends React.SVGAttributes<SVGSVGElement> {
filename?: string
mimeType?: string
testId?: string
}
export const AttachmentIcon = forwardRef<SVGSVGElement, AttachmentIconProps>(
({ filename, mimeType, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'attachment-icon',
identifier: customTestId || filename || 'attachment'
})
// Determine icon based on mime type
const getIconContent = () => {
if (mimeType?.startsWith('image/')) return '🖼️'
if (mimeType?.startsWith('video/')) return '🎬'
if (mimeType?.startsWith('audio/')) return '🎵'
if (mimeType === 'application/pdf') return '📄'
return '📎'
}
return (
<svg
ref={ref}
viewBox="0 0 24 24"
width="20"
height="20"
className="attachment-icon"
role="img"
aria-label={`Attachment: ${filename || 'document'}`}
{...accessible}
{...props}
>
<text x="50%" y="50%" dominantBaseline="middle" textAnchor="middle" fontSize="16">
{getIconContent()}
</text>
</svg>
)
}
)
AttachmentIcon.displayName = 'AttachmentIcon'

View File

@@ -0,0 +1,42 @@
import React, { forwardRef, useState } from 'react'
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface MarkAsReadCheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
isRead?: boolean
onToggleRead?: (read: boolean) => void
testId?: string
}
export const MarkAsReadCheckbox = forwardRef<HTMLInputElement, MarkAsReadCheckboxProps>(
({ isRead = false, onToggleRead, testId: customTestId, ...props }, ref) => {
const [read, setRead] = useState(isRead)
const accessible = useAccessible({
feature: 'email',
component: 'read-checkbox',
identifier: customTestId || 'read-status'
})
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newState = e.target.checked
setRead(newState)
onToggleRead?.(newState)
props.onChange?.(e)
}
return (
<input
ref={ref}
type="checkbox"
checked={read}
className="read-checkbox"
aria-label="Mark as read"
{...accessible}
{...props}
onChange={handleChange}
/>
)
}
)
MarkAsReadCheckbox.displayName = 'MarkAsReadCheckbox'

View File

@@ -0,0 +1,43 @@
import React, { forwardRef, useState } from 'react'
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface StarButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isStarred?: boolean
onToggleStar?: (starred: boolean) => void
testId?: string
}
export const StarButton = forwardRef<HTMLButtonElement, StarButtonProps>(
({ isStarred = false, onToggleStar, testId: customTestId, ...props }, ref) => {
const [starred, setStarred] = useState(isStarred)
const accessible = useAccessible({
feature: 'email',
component: 'star-button',
identifier: customTestId || 'star'
})
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const newState = !starred
setStarred(newState)
onToggleStar?.(newState)
props.onClick?.(e)
}
return (
<button
ref={ref}
className={`star-button ${starred ? 'star-button--active' : ''}`}
aria-pressed={starred}
title={starred ? 'Remove star' : 'Add star'}
{...accessible}
{...props}
onClick={handleClick}
>
{starred ? '⭐' : '☆'}
</button>
)
}
)
StarButton.displayName = 'StarButton'

View File

@@ -0,0 +1,3 @@
export { AttachmentIcon, type AttachmentIconProps } from './AttachmentIcon'
export { StarButton, type StarButtonProps } from './StarButton'
export { MarkAsReadCheckbox, type MarkAsReadCheckboxProps } from './MarkAsReadCheckbox'

View File

@@ -0,0 +1,87 @@
// fakemui/react/components/email/data-display/AttachmentList.tsx
import React, { forwardRef } from 'react'
import { Box, type BoxProps }
import { Typography }
import { Button }
import { useAccessible } from '@metabuilder/fakemui/hooks'
import { AttachmentIcon }
export interface Attachment {
id: string
filename: string
mimeType: string
size: number
downloadUrl?: string
}
export interface AttachmentListProps extends BoxProps {
attachments: Attachment[]
onDownload?: (attachment: Attachment) => void
onDelete?: (attachmentId: string) => void
testId?: string
}
export const AttachmentList = forwardRef<HTMLDivElement, AttachmentListProps>(
({ attachments, onDownload, onDelete, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'attachment-list',
identifier: customTestId || 'attachments'
})
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
return (
<Box
ref={ref}
className="attachment-list"
{...accessible}
{...props}
>
{attachments.length === 0 ? (
<Typography variant="body2" color="textSecondary">
No attachments
</Typography>
) : (
<ul className="attachment-items">
{attachments.map((attachment) => (
<li key={attachment.id} className="attachment-item">
<AttachmentIcon filename={attachment.filename} mimeType={attachment.mimeType} />
<div className="attachment-info">
<Typography variant="body2">{attachment.filename}</Typography>
<Typography variant="caption">{formatFileSize(attachment.size)}</Typography>
</div>
<div className="attachment-actions">
{attachment.downloadUrl && (
<Button
size="sm"
variant="ghost"
onClick={() => onDownload?.(attachment)}
>
Download
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => onDelete?.(attachment.id)}
>
Delete
</Button>
</div>
</li>
))}
</ul>
)}
</Box>
)
}
)
AttachmentList.displayName = 'AttachmentList'

View File

@@ -0,0 +1,76 @@
// fakemui/react/components/email/data-display/EmailHeader.tsx
import React, { forwardRef } from 'react'
import { Box, BoxProps, Typography }
import { useAccessible } from '@metabuilder/fakemui/hooks'
import { StarButton }
export interface EmailHeaderProps extends BoxProps {
from: string
to: string[]
cc?: string[]
subject: string
receivedAt: number
isStarred?: boolean
onToggleStar?: (starred: boolean) => void
testId?: string
}
export const EmailHeader = forwardRef<HTMLDivElement, EmailHeaderProps>(
(
{
from,
to,
cc,
subject,
receivedAt,
isStarred = false,
onToggleStar,
testId: customTestId,
...props
},
ref
) => {
const accessible = useAccessible({
feature: 'email',
component: 'email-header',
identifier: customTestId || subject
})
return (
<Box
ref={ref}
className="email-header"
{...accessible}
{...props}
>
<div className="header-top">
<Typography variant="h5" className="subject">
{subject}
</Typography>
<StarButton
isStarred={isStarred}
onToggleStar={onToggleStar}
/>
</div>
<div className="header-details">
<Typography variant="body2" className="from">
From: <strong>{from}</strong>
</Typography>
<Typography variant="body2" className="to">
To: <strong>{to.join(', ')}</strong>
</Typography>
{cc && cc.length > 0 && (
<Typography variant="body2" className="cc">
Cc: <strong>{cc.join(', ')}</strong>
</Typography>
)}
<Typography variant="caption" className="date">
{new Date(receivedAt).toLocaleString()}
</Typography>
</div>
</Box>
)
}
)
EmailHeader.displayName = 'EmailHeader'

View File

@@ -0,0 +1,84 @@
// fakemui/react/components/email/data-display/FolderTree.tsx
import React, { forwardRef, useState } from 'react'
import { Box, BoxProps, Typography }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface FolderNode {
id: string
name: string
unreadCount?: number
children?: FolderNode[]
type?: 'inbox' | 'sent' | 'drafts' | 'trash' | 'custom'
}
export interface FolderTreeProps extends BoxProps {
folders: FolderNode[]
selectedFolderId?: string
onSelectFolder?: (folderId: string) => void
testId?: string
}
export const FolderTree = forwardRef<HTMLDivElement, FolderTreeProps>(
({ folders, selectedFolderId, onSelectFolder, testId: customTestId, ...props }, ref) => {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
const accessible = useAccessible({
feature: 'email',
component: 'folder-tree',
identifier: customTestId || 'folders'
})
const toggleFolder = (folderId: string) => {
const newExpanded = new Set(expandedFolders)
if (newExpanded.has(folderId)) {
newExpanded.delete(folderId)
} else {
newExpanded.add(folderId)
}
setExpandedFolders(newExpanded)
}
const renderFolder = (folder: FolderNode, level: number = 0) => (
<div key={folder.id} className="folder-item" style={{ paddingLeft: `${level * 16}px` }}>
<button
className={`folder-btn ${selectedFolderId === folder.id ? 'folder-btn--active' : ''}`}
onClick={() => onSelectFolder?.(folder.id)}
>
{folder.children && folder.children.length > 0 && (
<span
className="folder-expand"
onClick={(e) => {
e.stopPropagation()
toggleFolder(folder.id)
}}
>
{expandedFolders.has(folder.id) ? '▼' : '▶'}
</span>
)}
<span className="folder-icon">📁</span>
<Typography variant="body2">{folder.name}</Typography>
{folder.unreadCount ? (
<span className="unread-badge">{folder.unreadCount}</span>
) : null}
</button>
{expandedFolders.has(folder.id) && folder.children && (
<div className="folder-children">
{folder.children.map((child) => renderFolder(child, level + 1))}
</div>
)}
</div>
)
return (
<Box
ref={ref}
className="folder-tree"
{...accessible}
{...props}
>
{folders.map((folder) => renderFolder(folder))}
</Box>
)
}
)
FolderTree.displayName = 'FolderTree'

View File

@@ -0,0 +1,60 @@
// fakemui/react/components/email/data-display/ThreadList.tsx
import React, { forwardRef } from 'react'
import { Box, BoxProps }
import { useAccessible } from '@metabuilder/fakemui/hooks'
import { EmailCard, type EmailCardProps }
export interface ThreadListProps extends BoxProps {
emails: Array<Omit<EmailCardProps, 'onSelect' | 'onToggleRead' | 'onToggleStar' | 'ref'>>
selectedEmailId?: string
onSelectEmail?: (emailId: string) => void
onToggleRead?: (emailId: string, read: boolean) => void
onToggleStar?: (emailId: string, starred: boolean) => void
testId?: string
}
export const ThreadList = forwardRef<HTMLDivElement, ThreadListProps>(
(
{
emails,
selectedEmailId,
onSelectEmail,
onToggleRead,
onToggleStar,
testId: customTestId,
...props
},
ref
) => {
const accessible = useAccessible({
feature: 'email',
component: 'thread-list',
identifier: customTestId || 'threads'
})
return (
<Box
ref={ref}
className="thread-list"
{...accessible}
{...props}
>
{emails.length === 0 ? (
<div className="no-emails">No emails</div>
) : (
emails.map((email, idx) => (
<EmailCard
key={idx}
{...email}
onSelect={() => onSelectEmail?.(email.testId || `email-${idx}`)}
onToggleRead={(read) => onToggleRead?.(email.testId || `email-${idx}`, read)}
onToggleStar={(starred) => onToggleStar?.(email.testId || `email-${idx}`, starred)}
/>
))
)}
</Box>
)
}
)
ThreadList.displayName = 'ThreadList'

View File

@@ -0,0 +1,4 @@
export { AttachmentList, type AttachmentListProps, type Attachment } from './AttachmentList'
export { EmailHeader, type EmailHeaderProps } from './EmailHeader'
export { FolderTree, type FolderTreeProps, type FolderNode } from './FolderTree'
export { ThreadList, type ThreadListProps } from './ThreadList'

View File

@@ -0,0 +1,56 @@
// fakemui/react/components/email/feedback/SyncProgress.tsx
import React, { forwardRef } from 'react'
import { Box, type BoxProps }
import { LinearProgress, Typography }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface SyncProgressProps extends BoxProps {
progress: number
totalMessages?: number
syncedMessages?: number
isVisible?: boolean
testId?: string
}
export const SyncProgress = forwardRef<HTMLDivElement, SyncProgressProps>(
(
{
progress,
totalMessages = 0,
syncedMessages = 0,
isVisible = true,
testId: customTestId,
...props
},
ref
) => {
const accessible = useAccessible({
feature: 'email',
component: 'sync-progress',
identifier: customTestId || 'progress'
})
if (!isVisible) {
return null
}
return (
<Box
ref={ref}
className="sync-progress"
{...accessible}
{...props}
>
<Typography variant="body2">
Syncing... {syncedMessages} of {totalMessages} messages
</Typography>
<LinearProgress variant="determinate" value={progress} />
<Typography variant="caption">
{Math.round(progress)}% complete
</Typography>
</Box>
)
}
)
SyncProgress.displayName = 'SyncProgress'

View File

@@ -0,0 +1,69 @@
// fakemui/react/components/email/feedback/SyncStatusBadge.tsx
import React, { forwardRef } from 'react'
import { Box, type BoxProps }
import { Chip }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export type SyncStatus = 'syncing' | 'synced' | 'error' | 'idle'
export interface SyncStatusBadgeProps extends BoxProps {
status: SyncStatus
lastSyncAt?: number
errorMessage?: string
testId?: string
}
export const SyncStatusBadge = forwardRef<HTMLDivElement, SyncStatusBadgeProps>(
({ status, lastSyncAt, errorMessage, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'sync-badge',
identifier: customTestId || status
})
const getStatusLabel = () => {
switch (status) {
case 'syncing':
return 'Syncing...'
case 'synced':
return `Last sync: ${lastSyncAt ? new Date(lastSyncAt).toLocaleTimeString() : 'now'}`
case 'error':
return `Sync failed: ${errorMessage || 'Unknown error'}`
case 'idle':
return 'Idle'
default:
return 'Unknown'
}
}
const getStatusColor = () => {
switch (status) {
case 'syncing':
return 'info'
case 'synced':
return 'success'
case 'error':
return 'error'
default:
return 'default'
}
}
return (
<Box
ref={ref}
className="sync-status-badge"
{...accessible}
{...props}
>
<Chip
label={getStatusLabel()}
color={getStatusColor()}
size="small"
/>
</Box>
)
}
)
SyncStatusBadge.displayName = 'SyncStatusBadge'

View File

@@ -0,0 +1,2 @@
export { SyncStatusBadge, type SyncStatusBadgeProps, type SyncStatus } from './SyncStatusBadge'
export { SyncProgress, type SyncProgressProps } from './SyncProgress'

View File

@@ -0,0 +1,75 @@
// Atoms
export {
AttachmentIcon,
type AttachmentIconProps,
StarButton,
type StarButtonProps,
MarkAsReadCheckbox,
type MarkAsReadCheckboxProps,
} from './atoms'
// Inputs
export {
EmailAddressInput,
type EmailAddressInputProps,
RecipientInput,
type RecipientInputProps,
BodyEditor,
type BodyEditorProps,
} from './inputs'
// Surfaces
export {
EmailCard,
type EmailCardProps,
MessageThread,
type MessageThreadProps,
ComposeWindow,
type ComposeWindowProps,
SignatureCard,
type SignatureCardProps,
} from './surfaces'
// Data Display
export {
AttachmentList,
type AttachmentListProps,
type Attachment,
EmailHeader,
type EmailHeaderProps,
FolderTree,
type FolderTreeProps,
type FolderNode,
ThreadList,
type ThreadListProps,
} from './data-display'
// Feedback
export {
SyncStatusBadge,
type SyncStatusBadgeProps,
type SyncStatus,
SyncProgress,
type SyncProgressProps,
} from './feedback'
// Layout
export {
MailboxLayout,
type MailboxLayoutProps,
ComposerLayout,
type ComposerLayoutProps,
SettingsLayout,
type SettingsLayoutProps,
type SettingsSection,
} from './layout'
// Navigation
export {
AccountTabs,
type AccountTabsProps,
type EmailAccount,
FolderNavigation,
type FolderNavigationProps,
type FolderNavigationItem,
} from './navigation'

View File

@@ -0,0 +1,50 @@
// fakemui/react/components/email/inputs/BodyEditor.tsx
import React, { forwardRef } from 'react'
import { Box } from '../../layout/Box'
import { useAccessible } from '../../../../src/utils/useAccessible'
export interface BodyEditorProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
mode?: 'plain' | 'html'
onModeChange?: (mode: 'plain' | 'html') => void
testId?: string
}
export const BodyEditor = forwardRef<HTMLTextAreaElement, BodyEditorProps>(
({ mode = 'plain', onModeChange, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'body-editor',
identifier: customTestId || 'body'
})
return (
<Box className="body-editor">
<div className="body-editor-toolbar">
<button
type="button"
className={`mode-btn ${mode === 'plain' ? 'mode-btn--active' : ''}`}
onClick={() => onModeChange?.('plain')}
>
Plain Text
</button>
<button
type="button"
className={`mode-btn ${mode === 'html' ? 'mode-btn--active' : ''}`}
onClick={() => onModeChange?.('html')}
>
HTML
</button>
</div>
<textarea
ref={ref}
className="body-editor-textarea"
placeholder="Write your message here..."
{...accessible}
{...props}
/>
</Box>
)
}
)
BodyEditor.displayName = 'BodyEditor'

View File

@@ -0,0 +1,48 @@
// fakemui/react/components/email/inputs/EmailAddressInput.tsx
import React, { forwardRef } from 'react'
import { TextField, TextFieldProps } from '../../inputs/TextField'
import { useAccessible } from '../../../../src/utils/useAccessible'
export interface EmailAddressInputProps extends Omit<TextFieldProps, 'type'> {
onValidate?: (valid: boolean) => void
allowMultiple?: boolean
}
export const EmailAddressInput = forwardRef<HTMLInputElement, EmailAddressInputProps>(
({ onValidate, allowMultiple = false, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'email-input',
identifier: customTestId || 'email'
})
const validateEmail = (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (allowMultiple) {
const emails = value.split(',').map(e => e.trim())
return emails.every(e => emailRegex.test(e) || e === '')
}
return emailRegex.test(value) || value === ''
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const valid = validateEmail(e.target.value)
onValidate?.(valid)
props.onChange?.(e)
}
return (
<TextField
ref={ref}
type="email"
label={props.label || (allowMultiple ? 'Recipients' : 'Email Address')}
placeholder={allowMultiple ? 'user@example.com, another@example.com' : 'user@example.com'}
{...accessible}
{...props}
onChange={handleChange}
/>
)
}
)
EmailAddressInput.displayName = 'EmailAddressInput'

View File

@@ -0,0 +1,67 @@
// fakemui/react/components/email/inputs/RecipientInput.tsx
import React, { forwardRef, useState } from 'react'
import { Box } from '../../layout/Box'
import { TextField } from '../../inputs/TextField'
import { Chip } from '../../data-display/Chip'
import { useAccessible } from '../../../../src/utils/useAccessible'
export interface RecipientInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
recipients?: string[]
onRecipientsChange?: (recipients: string[]) => void
recipientType?: 'to' | 'cc' | 'bcc'
testId?: string
}
export const RecipientInput = forwardRef<HTMLInputElement, RecipientInputProps>(
({ recipients = [], onRecipientsChange, recipientType = 'to', testId: customTestId, ...props }, ref) => {
const [inputValue, setInputValue] = useState('')
const accessible = useAccessible({
feature: 'email',
component: 'recipient-input',
identifier: customTestId || recipientType
})
const handleAddRecipient = () => {
if (inputValue && inputValue.includes('@')) {
const newRecipients = [...recipients, inputValue.trim()]
onRecipientsChange?.(newRecipients)
setInputValue('')
}
}
const handleRemoveRecipient = (index: number) => {
const newRecipients = recipients.filter((_, i) => i !== index)
onRecipientsChange?.(newRecipients)
}
// Filter out incompatible HTML input attributes
const { size: _size, ...textFieldProps } = props
return (
<Box className="recipient-input">
<div className="recipient-chips">
{recipients.map((recipient, index) => (
<Chip
key={index}
onDelete={() => handleRemoveRecipient(index)}
>
{recipient}
</Chip>
))}
</div>
<TextField
ref={ref}
type="email"
placeholder={`Add ${recipientType} recipient...`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddRecipient()}
{...accessible}
{...textFieldProps}
/>
</Box>
)
}
)
RecipientInput.displayName = 'RecipientInput'

View File

@@ -0,0 +1,3 @@
export { EmailAddressInput, type EmailAddressInputProps } from './EmailAddressInput'
export { RecipientInput, type RecipientInputProps } from './RecipientInput'
export { BodyEditor, type BodyEditorProps } from './BodyEditor'

View File

@@ -0,0 +1,34 @@
// fakemui/react/components/email/layout/ComposerLayout.tsx
import React, { forwardRef } from 'react'
import { Box, BoxProps }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface ComposerLayoutProps extends BoxProps {
form: React.ReactNode
preview?: React.ReactNode
testId?: string
}
export const ComposerLayout = forwardRef<HTMLDivElement, ComposerLayoutProps>(
({ form, preview, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'composer-layout',
identifier: customTestId || 'composer'
})
return (
<Box
ref={ref}
className="composer-layout"
{...accessible}
{...props}
>
<Box className="composer-form">{form}</Box>
{preview && <Box className="composer-preview">{preview}</Box>}
</Box>
)
}
)
ComposerLayout.displayName = 'ComposerLayout'

View File

@@ -0,0 +1,45 @@
// fakemui/react/components/email/layout/MailboxLayout.tsx
import React, { forwardRef } from 'react'
import { Box, type BoxProps }
import { AppBar, Toolbar }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface MailboxLayoutProps extends BoxProps {
sidebar: React.ReactNode
main: React.ReactNode
detail?: React.ReactNode
header?: React.ReactNode
testId?: string
}
export const MailboxLayout = forwardRef<HTMLDivElement, MailboxLayoutProps>(
({ sidebar, main, detail, header, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'mailbox-layout',
identifier: customTestId || 'mailbox'
})
return (
<Box
ref={ref}
className="mailbox-layout"
{...accessible}
{...props}
>
{header && (
<AppBar position="static" className="mailbox-header">
<Toolbar>{header}</Toolbar>
</AppBar>
)}
<Box className="mailbox-content">
<aside className="mailbox-sidebar">{sidebar}</aside>
<main className="mailbox-main">{main}</main>
{detail && <article className="mailbox-detail">{detail}</article>}
</Box>
</Box>
)
}
)
MailboxLayout.displayName = 'MailboxLayout'

View File

@@ -0,0 +1,49 @@
// fakemui/react/components/email/layout/SettingsLayout.tsx
import React, { forwardRef } from 'react'
import { Box, BoxProps, Tabs, Tab }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface SettingsSection {
id: string
label: string
content: React.ReactNode
}
export interface SettingsLayoutProps extends BoxProps {
sections: SettingsSection[]
activeSection?: string
onSectionChange?: (sectionId: string) => void
testId?: string
}
export const SettingsLayout = forwardRef<HTMLDivElement, SettingsLayoutProps>(
({ sections, activeSection, onSectionChange, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'settings-layout',
identifier: customTestId || 'settings'
})
const current = activeSection || sections[0]?.id
return (
<Box
ref={ref}
className="settings-layout"
{...accessible}
{...props}
>
<Tabs value={current} onChange={(_, value) => onSectionChange?.(value as string)}>
{sections.map((section) => (
<Tab key={section.id} label={section.label} value={section.id} />
))}
</Tabs>
<Box className="settings-content">
{sections.find((s) => s.id === current)?.content}
</Box>
</Box>
)
}
)
SettingsLayout.displayName = 'SettingsLayout'

View File

@@ -0,0 +1,3 @@
export { MailboxLayout, type MailboxLayoutProps } from './MailboxLayout'
export { ComposerLayout, type ComposerLayoutProps } from './ComposerLayout'
export { SettingsLayout, type SettingsLayoutProps, type SettingsSection } from './SettingsLayout'

View File

@@ -0,0 +1,54 @@
import React, { forwardRef } from 'react'
import { Tabs, Tab, TabsProps }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface EmailAccount {
id: string
accountName: string
emailAddress: string
unreadCount?: number
}
export interface AccountTabsProps extends Omit<TabsProps, 'onChange'> {
accounts: EmailAccount[]
selectedAccountId?: string
onSelectAccount?: (accountId: string) => void
testId?: string
}
export const AccountTabs = forwardRef<HTMLDivElement, AccountTabsProps>(
({ accounts, selectedAccountId, onSelectAccount, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'account-tabs',
identifier: customTestId || 'accounts'
})
return (
<Tabs
ref={ref}
value={selectedAccountId || (accounts[0]?.id ?? '')}
onChange={(_, value) => onSelectAccount?.(value as string)}
{...accessible}
{...props}
>
{accounts.map((account) => (
<Tab
key={account.id}
label={
<span className="account-tab">
<span className="account-name">{account.accountName}</span>
{account.unreadCount ? (
<span className="unread-badge">{account.unreadCount}</span>
) : null}
</span>
}
value={account.id}
/>
))}
</Tabs>
)
}
)
AccountTabs.displayName = 'AccountTabs'

View File

@@ -0,0 +1,56 @@
import React, { forwardRef } from 'react'
import { Box, BoxProps, Button }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface FolderNavigationItem {
id: string
label: string
icon?: string
unreadCount?: number
isActive?: boolean
}
export interface FolderNavigationProps extends BoxProps {
items: FolderNavigationItem[]
onNavigate?: (itemId: string) => void
testId?: string
}
export const FolderNavigation = forwardRef<HTMLDivElement, FolderNavigationProps>(
({ items, onNavigate, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'folder-nav',
identifier: customTestId || 'folders'
})
return (
<Box
ref={ref}
className="folder-navigation"
{...accessible}
{...props}
>
<nav className="folder-nav-list">
{items.map((item) => (
<Button
key={item.id}
variant={item.isActive ? 'primary' : 'ghost'}
fullWidth
className="folder-nav-item"
onClick={() => onNavigate?.(item.id)}
>
{item.icon && <span className="folder-icon">{item.icon}</span>}
<span className="folder-label">{item.label}</span>
{item.unreadCount ? (
<span className="unread-count">{item.unreadCount}</span>
) : null}
</Button>
))}
</nav>
</Box>
)
}
)
FolderNavigation.displayName = 'FolderNavigation'

View File

@@ -0,0 +1,2 @@
export { AccountTabs, type AccountTabsProps, type EmailAccount } from './AccountTabs'
export { FolderNavigation, type FolderNavigationProps, type FolderNavigationItem } from './FolderNavigation'

View File

@@ -0,0 +1,90 @@
// fakemui/react/components/email/surfaces/ComposeWindow.tsx
import React, { forwardRef, useState } from 'react'
import { Box, BoxProps, Button, Card }
import { useAccessible } from '@metabuilder/fakemui/hooks'
import { EmailAddressInput, RecipientInput, BodyEditor }
export interface ComposeWindowProps extends BoxProps {
onSend?: (data: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; body: string }) => void
onClose?: () => void
testId?: string
}
export const ComposeWindow = forwardRef<HTMLDivElement, ComposeWindowProps>(
({ onSend, onClose, testId: customTestId, ...props }, ref) => {
const [to, setTo] = useState<string[]>([])
const [cc, setCc] = useState<string[]>([])
const [bcc, setBcc] = useState<string[]>([])
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const accessible = useAccessible({
feature: 'email',
component: 'compose',
identifier: customTestId || 'compose'
})
const handleSend = () => {
if (to.length > 0 && subject && body) {
onSend?.({ to, cc, bcc, subject, body })
}
}
return (
<Card
ref={ref}
className="compose-window"
{...accessible}
{...props}
>
<Box className="compose-header">
<h2>Compose Email</h2>
<button onClick={onClose} className="close-btn">
×
</button>
</Box>
<Box className="compose-body">
<RecipientInput
recipientType="to"
recipients={to}
onRecipientsChange={setTo}
placeholder="To:"
/>
<RecipientInput
recipientType="cc"
recipients={cc}
onRecipientsChange={setCc}
placeholder="Cc:"
/>
<RecipientInput
recipientType="bcc"
recipients={bcc}
onRecipientsChange={setBcc}
placeholder="Bcc:"
/>
<input
type="text"
placeholder="Subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="compose-subject"
/>
<BodyEditor
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</Box>
<Box className="compose-footer">
<Button variant="primary" onClick={handleSend}>
Send
</Button>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
</Box>
</Card>
)
}
)
ComposeWindow.displayName = 'ComposeWindow'

View File

@@ -0,0 +1,93 @@
// fakemui/react/components/email/surfaces/EmailCard.tsx
import React, { forwardRef } from 'react'
import { Card }
import { Box }
import { Typography }
import { useAccessible } from '@metabuilder/fakemui/hooks'
import { MarkAsReadCheckbox, StarButton }
export interface EmailCardProps extends CardProps {
from: string
subject: string
preview: string
receivedAt: number
isRead: boolean
isStarred?: boolean
onSelect?: () => void
onToggleRead?: (read: boolean) => void
onToggleStar?: (starred: boolean) => void
testId?: string
}
export const EmailCard = forwardRef<HTMLDivElement, EmailCardProps>(
(
{
from,
subject,
preview,
receivedAt,
isRead,
isStarred = false,
onSelect,
onToggleRead,
onToggleStar,
testId: customTestId,
...props
},
ref
) => {
const accessible = useAccessible({
feature: 'email',
component: 'card',
identifier: customTestId || subject.substring(0, 20)
})
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
const today = new Date()
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
return (
<Card
ref={ref}
className={`email-card ${isRead ? 'email-card--read' : 'email-card--unread'}`}
onClick={onSelect}
{...accessible}
{...props}
>
<Box className="email-card-header">
<MarkAsReadCheckbox
isRead={isRead}
onToggleRead={onToggleRead}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="subtitle2" className="email-from">
{from}
</Typography>
<div className="email-card-actions">
<StarButton
isStarred={isStarred}
onToggleStar={onToggleStar}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="caption" className="email-date">
{formatDate(receivedAt)}
</Typography>
</div>
</Box>
<Typography variant="h6" className="email-subject">
{subject}
</Typography>
<Typography variant="body2" className="email-preview" noWrap>
{preview}
</Typography>
</Card>
)
}
)
EmailCard.displayName = 'EmailCard'

View File

@@ -0,0 +1,54 @@
// fakemui/react/components/email/surfaces/MessageThread.tsx
import React, { forwardRef } from 'react'
import { Box, BoxProps, Typography, Card }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface MessageThreadProps extends BoxProps {
messages: Array<{
id: string
from: string
subject: string
body: string
receivedAt: number
isStarred?: boolean
}>
testId?: string
}
export const MessageThread = forwardRef<HTMLDivElement, MessageThreadProps>(
({ messages, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'thread',
identifier: customTestId || 'thread'
})
return (
<Box
ref={ref}
className="message-thread"
{...accessible}
{...props}
>
{messages.map((message, index) => (
<Card
key={message.id}
className={`message-item ${index === messages.length - 1 ? 'message-item--latest' : ''}`}
>
<Box className="message-header">
<Typography variant="subtitle2">{message.from}</Typography>
<Typography variant="caption">
{new Date(message.receivedAt).toLocaleString()}
</Typography>
</Box>
<Typography variant="body2" className="message-body">
{message.body}
</Typography>
</Card>
))}
</Box>
)
}
)
MessageThread.displayName = 'MessageThread'

View File

@@ -0,0 +1,45 @@
// fakemui/react/components/email/surfaces/SignatureCard.tsx
import React, { forwardRef } from 'react'
import { Card, CardProps, Typography }
import { useAccessible } from '@metabuilder/fakemui/hooks'
export interface SignatureCardProps extends CardProps {
text: string
editMode?: boolean
onEdit?: (text: string) => void
testId?: string
}
export const SignatureCard = forwardRef<HTMLDivElement, SignatureCardProps>(
({ text, editMode = false, onEdit, testId: customTestId, ...props }, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'signature',
identifier: customTestId || 'signature'
})
return (
<Card
ref={ref}
className="signature-card"
{...accessible}
{...props}
>
{editMode ? (
<textarea
value={text}
onChange={(e) => onEdit?.(e.target.value)}
className="signature-editor"
placeholder="Add your signature here..."
/>
) : (
<Typography variant="body2" className="signature-text">
{text}
</Typography>
)}
</Card>
)
}
)
SignatureCard.displayName = 'SignatureCard'

View File

@@ -0,0 +1,5 @@
// fakemui/react/components/email/surfaces/index.ts
export { EmailCard, type EmailCardProps } from './EmailCard'
export { MessageThread, type MessageThreadProps } from './MessageThread'
export { ComposeWindow, type ComposeWindowProps } from './ComposeWindow'
export { SignatureCard, type SignatureCardProps } from './SignatureCard'

View File

@@ -0,0 +1,27 @@
export { Button } from './Button'
export { ButtonGroup } from './ButtonGroup'
export { IconButton } from './IconButton'
export { Fab } from './Fab'
export { Input } from './Input'
export { Textarea } from './Textarea'
export { Select } from './Select'
export { NativeSelect } from './NativeSelect'
export { Checkbox } from './Checkbox'
export { Radio } from './Radio'
export { RadioGroup, useRadioGroup } from './RadioGroup'
export { Switch } from './Switch'
export { Slider } from './Slider'
export { FormControl, useFormControl } from './FormControl'
export { FormGroup } from './FormGroup'
export { FormLabel } from './FormLabel'
export { FormHelperText } from './FormHelperText'
export { TextField } from './TextField'
export { ToggleButton, ToggleButtonGroup } from './ToggleButton'
export { Autocomplete } from './Autocomplete'
export { Rating } from './Rating'
export { ButtonBase, InputBase, FilledInput, OutlinedInput } from './InputBase'
export { FormField } from './FormField'
export { DatePicker } from './DatePicker'
export { TimePicker } from './TimePicker'
export { ColorPicker } from './ColorPicker'
export { FileUpload } from './FileUpload'

View File

@@ -0,0 +1,5 @@
export { Paper } from './Paper'
export { Card, CardHeader, CardContent, CardActions, CardActionArea, CardMedia, CardTitle, CardDescription, CardFooter } from './Card'
export { Accordion, AccordionSummary, AccordionDetails, AccordionActions } from './Accordion'
export { AppBar, Toolbar } from './AppBar'
export { Drawer } from './Drawer'