refactor(emailclient): full a11y pass, component decomposition, keyboard shortcuts

Accessibility:
- All components: data-testid, aria-label, aria-pressed, aria-current
- ComposeWindow: role=dialog, aria-modal, focus trap, Escape handler
- EmailCard: role=article, keyboard nav (Enter/Space), aria-current
- ThreadList: role=list with listitem wrappers, role=status empty state
- FolderNavigation: role=navigation, role=list, aria-current
- RecipientInput: role=group, aria-label per type, onKeyPress→onKeyDown
- BodyEditor: role=toolbar, aria-pressed on mode buttons
- StarButton: MaterialIcon (star/star_border), dynamic aria-label
- MarkAsReadCheckbox: dynamic label based on read state
- EmailHeader: role=banner, <time> element, data-testids

Component decomposition:
- Extract useEmailClient hook (state + callbacks)
- Extract demo-emails.ts (data constants)
- EmailClientContent.tsx: 267→127 LOC (composition only)

New: useKeyboardShortcuts hook (Gmail-style, 47 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:51:54 +00:00
parent 6e394d7846
commit f22caa6e16
15 changed files with 884 additions and 453 deletions

View File

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

View File

@@ -1,43 +1,54 @@
// fakemui/email/atoms/StarButton.tsx
import React, { forwardRef, useState } from 'react'
import { MaterialIcon } from '../../../../icons/react/fakemui'
import { useAccessible } from '../../../../hooks/useAccessible'
export interface StarButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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)
export const StarButton = forwardRef<
HTMLButtonElement,
StarButtonProps
>(({ isStarred = false, onToggleStar, testId, ...props }, ref) => {
const [starred, setStarred] = useState(isStarred)
const accessible = useAccessible({
feature: 'email',
component: 'star-button',
identifier: customTestId || 'star'
})
const accessible = useAccessible({
feature: 'email',
component: 'star-button',
identifier: testId || '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>
)
const handleClick = (
e: React.MouseEvent<HTMLButtonElement>
) => {
const next = !starred
setStarred(next)
onToggleStar?.(next)
props.onClick?.(e)
}
)
const label = starred ? 'Remove star' : 'Add star'
const icon = starred ? 'star' : 'star_border'
return (
<button
ref={ref}
className={
`star-button${starred ? ' star-button--active' : ''}`
}
aria-pressed={starred}
aria-label={label}
{...accessible}
{...props}
onClick={handleClick}
>
<MaterialIcon name={icon} fill={starred ? 1 : 0} />
</button>
)
})
StarButton.displayName = 'StarButton'

View File

@@ -31,15 +31,23 @@ export const EmailHeader = ({
component: 'email-header',
identifier: customTestId || subject
})
const iso = new Date(receivedAt).toISOString()
const display = new Date(receivedAt).toLocaleString()
return (
<Box
role="banner"
aria-label="Email details"
className="email-header"
{...accessible}
{...props}
>
<div className="header-top">
<Typography variant="h5" className="subject">
<Typography
variant="h5"
id="email-subject"
className="subject"
>
{subject}
</Typography>
<StarButton
@@ -48,19 +56,35 @@ export const EmailHeader = ({
/>
</div>
<div className="header-details">
<Typography variant="body2" className="from">
<Typography
variant="body2"
className="from"
data-testid="email-from"
>
From: <strong>{from}</strong>
</Typography>
<Typography variant="body2" className="to">
<Typography
variant="body2"
className="to"
data-testid="email-to"
>
To: <strong>{to.join(', ')}</strong>
</Typography>
{cc && cc.length > 0 && (
<Typography variant="body2" className="cc">
<Typography
variant="body2"
className="cc"
data-testid="email-cc"
>
Cc: <strong>{cc.join(', ')}</strong>
</Typography>
)}
<Typography variant="caption" className="date">
{new Date(receivedAt).toLocaleString()}
<Typography
variant="caption"
className="date"
data-testid="email-date"
>
<time dateTime={iso}>{display}</time>
</Typography>
</div>
</Box>

View File

@@ -5,11 +5,14 @@ import { useAccessible } from '../../../../hooks/useAccessible'
import { EmailCard, type EmailCardProps } from '../surfaces'
export interface ThreadListProps extends BoxProps {
emails: Array<Omit<EmailCardProps, 'onSelect' | 'onToggleRead' | 'onToggleStar'>>
emails: Array<Omit<
EmailCardProps,
'onSelect' | 'onToggleRead' | 'onToggleStar'
>>
selectedEmailId?: string
onSelectEmail?: (emailId: string) => void
onToggleRead?: (emailId: string, read: boolean) => void
onToggleStar?: (emailId: string, starred: boolean) => void
onToggleRead?: (id: string, read: boolean) => void
onToggleStar?: (id: string, star: boolean) => void
testId?: string
}
@@ -28,23 +31,44 @@ export const ThreadList = ({
identifier: customTestId || 'threads'
})
const emailId = (e: typeof emails[0], i: number) =>
e.testId || `email-${i}`
return (
<Box
role="list"
aria-label="Email messages"
className="thread-list"
{...accessible}
{...props}
>
{emails.length === 0 ? (
<div className="no-emails">No emails</div>
<div className="no-emails" role="status">
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)}
/>
<div role="listitem" key={idx}>
<EmailCard
{...email}
selected={
selectedEmailId === email.testId
}
onSelect={() =>
onSelectEmail?.(emailId(email, idx))
}
onToggleRead={(read) =>
onToggleRead?.(
emailId(email, idx), read
)
}
onToggleStar={(starred) =>
onToggleStar?.(
emailId(email, idx), starred
)
}
/>
</div>
))
)}
</Box>

View File

@@ -3,48 +3,62 @@ import React, { forwardRef } from 'react'
import { Box } from '../../layout/Box'
import { useAccessible } from '../../../../hooks/useAccessible'
export interface BodyEditorProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
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'
})
export const BodyEditor = forwardRef<
HTMLTextAreaElement,
BodyEditorProps
>(({
mode = 'plain',
onModeChange,
testId: customTestId,
...props
}, ref) => {
const accessible = useAccessible({
feature: 'email',
component: 'body-editor',
identifier: customTestId || 'body',
ariaLabel: 'Email 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>
)
}
)
const modeBtn = (m: 'plain' | 'html', label: string) => (
<button
type="button"
className={`mode-btn${mode === m ? ' mode-btn--active' : ''}`}
onClick={() => onModeChange?.(m)}
aria-pressed={mode === m}
data-testid={`mode-${m}`}
>
{label}
</button>
)
return (
<Box className="body-editor">
<div
className="body-editor-toolbar"
role="toolbar"
aria-label="Editor mode"
>
{modeBtn('plain', 'Plain Text')}
{modeBtn('html', 'HTML')}
</div>
<textarea
ref={ref}
className="body-editor-textarea"
placeholder="Write your message here..."
{...accessible}
{...props}
/>
</Box>
)
})
BodyEditor.displayName = 'BodyEditor'

View File

@@ -5,63 +5,80 @@ import { TextField } from '../../inputs/TextField'
import { Chip } from '../../data-display/Chip'
import { useAccessible } from '../../../../hooks/useAccessible'
export interface RecipientInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
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
})
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,
ariaLabel: `Add ${recipientType} recipient`
})
const handleAddRecipient = () => {
if (inputValue && inputValue.includes('@')) {
const newRecipients = [...recipients, inputValue.trim()]
onRecipientsChange?.(newRecipients)
setInputValue('')
}
const addRecipient = () => {
if (inputValue && inputValue.includes('@')) {
onRecipientsChange?.([
...recipients, inputValue.trim()
])
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>
const removeRecipient = (index: number) => {
onRecipientsChange?.(
recipients.filter((_, i) => i !== index)
)
}
)
const { size: _size, ...textFieldProps } = props
return (
<Box
className="recipient-input"
role="group"
aria-label={`${recipientType.toUpperCase()} recipients`}
>
<div className="recipient-chips">
{recipients.map((r, i) => (
<Chip
key={i}
onDelete={() => removeRecipient(i)}
aria-label={`Remove ${r}`}
>
{r}
</Chip>
))}
</div>
<TextField
ref={ref}
type="email"
placeholder={`Add ${recipientType} recipient...`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && addRecipient()
}
{...accessible}
{...textFieldProps}
/>
</Box>
)
})
RecipientInput.displayName = 'RecipientInput'

View File

@@ -44,31 +44,64 @@ export const EmailDetail = ({
})
return (
<Box className="email-detail" {...accessible} {...props}>
<Box className="email-detail-toolbar">
<Box
role="article"
aria-label={email.subject}
className="email-detail"
{...accessible}
{...props}
>
<Box
id="email-detail-toolbar"
className="email-detail-toolbar"
>
{onClose && (
<IconButton aria-label="Back to list" className="email-detail-back" onClick={onClose}>
<IconButton
aria-label="Back to list"
data-testid="email-back"
onClick={onClose}
>
<MaterialIcon name="arrow_back" size={20} />
</IconButton>
)}
<Box className="email-detail-actions">
{onArchive && (
<IconButton aria-label="Archive" title="Archive" onClick={onArchive}>
<IconButton
aria-label="Archive"
title="Archive"
data-testid="email-archive"
onClick={onArchive}
>
<MaterialIcon name="archive" size={20} />
</IconButton>
)}
{onDelete && (
<IconButton aria-label="Delete" title="Delete" onClick={onDelete}>
<IconButton
aria-label="Delete"
title="Delete"
data-testid="email-delete"
onClick={onDelete}
>
<MaterialIcon name="delete" size={20} />
</IconButton>
)}
{onReply && (
<IconButton aria-label="Reply" title="Reply" onClick={onReply}>
<IconButton
aria-label="Reply"
title="Reply"
data-testid="email-reply"
onClick={onReply}
>
<MaterialIcon name="reply" size={20} />
</IconButton>
)}
{onForward && (
<IconButton aria-label="Forward" title="Forward" onClick={onForward}>
<IconButton
aria-label="Forward"
title="Forward"
data-testid="email-forward"
onClick={onForward}
>
<MaterialIcon name="forward" size={20} />
</IconButton>
)}
@@ -85,19 +118,33 @@ export const EmailDetail = ({
onToggleStar={onToggleStar}
/>
<Box className="email-detail-body">
<Box
id="email-detail-body"
className="email-detail-body"
>
{email.body}
</Box>
<Box className="email-detail-reply-bar">
<Box
id="email-detail-reply-bar"
className="email-detail-reply-bar"
>
{onReply && (
<Button variant="outlined" className="email-detail-reply-btn" onClick={onReply}>
<Button
variant="outlined"
data-testid="email-reply-btn"
onClick={onReply}
>
<MaterialIcon name="reply" size={16} />
Reply
</Button>
)}
{onForward && (
<Button variant="outlined" className="email-detail-reply-btn" onClick={onForward}>
<Button
variant="outlined"
data-testid="email-forward-btn"
onClick={onForward}
>
<MaterialIcon name="forward" size={16} />
Forward
</Button>

View File

@@ -31,24 +31,37 @@ export const FolderNavigation = ({
return (
<Box
className="folder-navigation"
role="navigation"
aria-label="Mail folders"
{...accessible}
{...props}
>
<nav className="folder-nav-list">
<nav className="folder-nav-list" role="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>
<div key={item.id} role="listitem">
<Button
variant={item.isActive ? 'primary' : 'ghost'}
fullWidth
className="folder-nav-item"
onClick={() => onNavigate?.(item.id)}
aria-current={item.isActive || undefined}
data-testid={`folder-nav-${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>
</div>
))}
</nav>
</Box>

View File

@@ -1,87 +1,88 @@
// fakemui/react/components/email/surfaces/ComposeWindow.tsx
import React, { useState } from 'react'
// fakemui/react/components/email/surfaces/ComposeWindow
import React, { useCallback, useEffect, useState } from 'react'
import { Box, BoxProps, Button, Card } from '../..'
import { useAccessible } from '../../../../hooks/useAccessible'
import { EmailAddressInput, RecipientInput, BodyEditor } from '../inputs'
import {
useAccessible, useFocusTrap
} from '../../../../hooks/useAccessible'
import { MaterialIcon } from '../../../../icons/react/fakemui'
import { RecipientInput, BodyEditor } from '../inputs'
export interface ComposeWindowProps extends BoxProps {
onSend?: (data: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; body: string }) => void
onSend?: (data: {
to: string[]; cc?: string[]; bcc?: string[]
subject: string; body: string
}) => void
onClose?: () => void
testId?: string
}
export const ComposeWindow = ({
onSend,
onClose,
testId: customTestId,
...props
onSend, onClose, testId: customTestId, ...props
}: ComposeWindowProps) => {
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',
feature: 'email', component: 'compose',
identifier: customTestId || 'compose'
})
const trapRef = useFocusTrap(true)
const handleSend = () => {
if (to.length > 0 && subject && body) {
if (to.length > 0 && subject && body)
onSend?.({ to, cc, bcc, subject, body })
}
}
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose?.()
}, [onClose]
)
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () =>
document.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
return (
<Card
className="compose-window"
{...accessible}
{...props}
>
<Card ref={trapRef} className="compose-window"
role="dialog" aria-modal={true}
aria-labelledby="compose-dialog-title"
{...accessible} {...props}>
<Box className="compose-header">
<h2>Compose Email</h2>
<button onClick={onClose} className="close-btn">
×
<h2 id="compose-dialog-title">Compose Email</h2>
<button onClick={onClose} className="close-btn"
aria-label="Close"
data-testid="compose-close-btn">
<MaterialIcon name="close" />
</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)}
/>
<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} id="compose-subject"
aria-label="Subject"
data-testid="compose-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 variant="primary" onClick={handleSend}
data-testid="compose-send-btn">
<MaterialIcon name="send" /> Send
</Button>
<Button variant="outline" onClick={onClose}>
<Button variant="outline" onClick={onClose}
data-testid="compose-cancel-btn">
Cancel
</Button>
</Box>

View File

@@ -11,6 +11,7 @@ export interface EmailCardProps extends CardProps {
receivedAt: number
isRead: boolean
isStarred?: boolean
selected?: boolean
onSelect?: () => void
onToggleRead?: (read: boolean) => void
onToggleStar?: (starred: boolean) => void
@@ -24,6 +25,7 @@ export const EmailCard = ({
receivedAt,
isRead,
isStarred = false,
selected,
onSelect,
onToggleRead,
onToggleStar,
@@ -36,19 +38,40 @@ export const EmailCard = ({
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' })
const date = new Date(receivedAt)
const today = new Date()
const isToday =
date.toDateString() === today.toDateString()
const displayDate = isToday
? date.toLocaleTimeString(
[], { hour: '2-digit', minute: '2-digit' }
)
: date.toLocaleDateString(
[], { month: 'short', day: 'numeric' }
)
const handleKeyDown = (
e: React.KeyboardEvent
) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect?.()
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
return (
<Card
className={`email-card ${isRead ? 'email-card--read' : 'email-card--unread'}`}
role="article"
aria-label={`Email from ${from}: ${subject}`}
aria-current={selected ? 'true' : undefined}
tabIndex={0}
className={
`email-card ${isRead
? 'email-card--read'
: 'email-card--unread'}`
}
onClick={onSelect}
onKeyDown={handleKeyDown}
{...accessible}
{...props}
>
@@ -58,7 +81,10 @@ export const EmailCard = ({
onToggleRead={onToggleRead}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="subtitle2" className="email-from">
<Typography
variant="subtitle2"
className="email-from"
>
{from}
</Typography>
<div className="email-card-actions">
@@ -67,15 +93,27 @@ export const EmailCard = ({
onToggleStar={onToggleStar}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="caption" className="email-date">
{formatDate(receivedAt)}
</Typography>
<time dateTime={date.toISOString()}>
<Typography
variant="caption"
className="email-date"
>
{displayDate}
</Typography>
</time>
</div>
</Box>
<Typography variant="h6" className="email-subject">
<Typography
variant="h6"
className="email-subject"
>
{subject}
</Typography>
<Typography variant="body2" className="email-preview" noWrap>
<Typography
variant="body2"
className="email-preview"
noWrap
>
{preview}
</Typography>
</Card>

View File

@@ -1,197 +1,38 @@
'use client'
import React, { useState, useCallback } from 'react'
import React from 'react'
import {
MailboxLayout,
MailboxHeader,
MailboxSidebar,
EmailDetail,
type FolderNavigationItem,
ThreadList,
ComposeWindow,
} from '@metabuilder/fakemui/email'
import { Box, Typography } from '@metabuilder/fakemui'
// ─────────────────────────────────────────────────────────────────────────────
// Demo data — replace with useMessages/useMailboxes hooks when IMAP backend is ready
// ─────────────────────────────────────────────────────────────────────────────
const DEMO_FOLDERS: FolderNavigationItem[] = [
{ id: 'inbox', label: 'Inbox', icon: 'inbox', unreadCount: 3, isActive: true },
{ id: 'starred', label: 'Starred', icon: 'star' },
{ id: 'sent', label: 'Sent', icon: 'send' },
{ id: 'drafts', label: 'Drafts', icon: 'drafts', unreadCount: 1 },
{ id: 'spam', label: 'Spam', icon: 'report' },
{ id: 'trash', label: 'Trash', icon: 'delete' },
]
const now = Date.now()
const hour = 3600000
const day = 86400000
const DEMO_EMAILS = [
{
id: '1',
testId: '1',
from: 'Alice Chen',
to: ['you@metabuilder.io'],
subject: 'Sprint planning for next week',
preview: 'Hey team, I\'ve put together the backlog items we need to discuss in our sprint planning session...',
receivedAt: now - 2 * hour,
isRead: false,
isStarred: true,
body: 'Hey team,\n\nI\'ve put together the backlog items we need to discuss in our sprint planning session on Monday. Please review the board before the meeting.\n\nKey items:\n- DBAL multi-adapter support\n- Email client backend integration\n- Workflow engine performance improvements\n\nLet me know if you have anything to add.\n\nBest,\nAlice',
},
{
id: '2',
testId: '2',
from: 'GitHub',
to: ['you@metabuilder.io'],
subject: '[metabuilder] PR #847: Fix loadFromDirectory tenantId auto-add',
preview: 'mergify[bot] merged pull request #847. loadFromDirectory() was missing the tenantId field...',
receivedAt: now - 5 * hour,
isRead: false,
isStarred: false,
body: 'mergify[bot] merged pull request #847.\n\nloadFromDirectory() was missing the tenantId field auto-add logic that loadFromFile() already had, causing "Unknown field: tenantId" on all entities using the shorthand convention.\n\nFiles changed: 2\nAdditions: 51\nDeletions: 22',
},
{
id: '3',
testId: '3',
from: 'Bob Martinez',
to: ['you@metabuilder.io'],
cc: ['alice@metabuilder.io'],
subject: 'Re: Database adapter benchmarks',
preview: 'The PostgreSQL adapter is showing 3x throughput improvement with the new connection pool settings...',
receivedAt: now - 1 * day,
isRead: false,
isStarred: false,
body: 'The PostgreSQL adapter is showing 3x throughput improvement with the new connection pool settings. Here are the results:\n\n- SQLite: 12,400 ops/sec (dev baseline)\n- PostgreSQL: 8,900 ops/sec → 26,700 ops/sec\n- MySQL: 7,200 ops/sec (unchanged)\n\nI\'ll push the config changes to the deployment branch.\n\nBob',
},
{
id: '4',
testId: '4',
from: 'Docker Hub',
to: ['you@metabuilder.io'],
subject: 'Build succeeded: metabuilder/dbal:latest',
preview: 'Your automated build for metabuilder/dbal has completed successfully. Image size: 487MB...',
receivedAt: now - 1 * day - 3 * hour,
isRead: true,
isStarred: false,
body: 'Your automated build for metabuilder/dbal has completed successfully.\n\nImage: metabuilder/dbal:latest\nSize: 487MB\nBuild time: 4m 23s\nLayers: 12',
},
{
id: '5',
testId: '5',
from: 'Carol Wu',
to: ['you@metabuilder.io'],
subject: 'FakeMUI component audit results',
preview: 'Finished the component audit — 167 components across 11 categories. All tests passing...',
receivedAt: now - 2 * day,
isRead: true,
isStarred: true,
body: 'Finished the component audit — 167 components across 11 categories. All tests passing.\n\nBreakdown:\n- Core React: 145 components\n- Email: 22 components\n- Icons: 421\n- SCSS modules: 78\n\nThe QML ports are at 104 components. Python bindings cover 15 of the most-used ones.\n\nCarol',
},
{
id: '6',
testId: '6',
from: 'Sentry',
to: ['you@metabuilder.io'],
subject: 'New issue: DBAL fetch failed: 422 Unprocessable Entity',
preview: 'A new error was detected in codegen-frontend. fetchFromDBAL threw an error for unknown entity...',
receivedAt: now - 3 * day,
isRead: true,
isStarred: false,
body: 'A new error was detected in codegen-frontend.\n\nError: DBAL fetch failed: 422 Unprocessable Entity\nFile: src/store/middleware/dbalSync.ts:131\nOccurrences: 47\nUsers affected: 3\n\nStack trace:\nfetchFromDBAL @ dbalSync.ts:131\nloadSettings @ layout.tsx:33',
},
]
// ─────────────────────────────────────────────────────────────────────────────
// Email Client App
// ─────────────────────────────────────────────────────────────────────────────
import { useEmailClient } from './hooks/useEmailClient'
export default function EmailClientContent() {
const [activeFolder, setActiveFolder] = useState('inbox')
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
const [emails, setEmails] = useState(DEMO_EMAILS)
const [showCompose, setShowCompose] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isDarkMode, setIsDarkMode] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(true)
const folders = DEMO_FOLDERS.map(f => ({
...f,
isActive: f.id === activeFolder,
}))
const selectedEmail = emails.find(e => e.id === selectedEmailId) || null
const filteredEmails = emails.filter(e => {
if (activeFolder === 'starred') return e.isStarred
if (activeFolder === 'sent') return false
if (activeFolder === 'drafts') return false
if (activeFolder === 'spam') return false
if (activeFolder === 'trash') return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
return (
e.from.toLowerCase().includes(q) ||
e.subject.toLowerCase().includes(q) ||
e.preview.toLowerCase().includes(q)
)
}
return true
})
const handleSelectEmail = useCallback((emailId: string) => {
setSelectedEmailId(emailId)
setEmails(prev => prev.map(e => e.id === emailId ? { ...e, isRead: true } : e))
}, [])
const handleToggleRead = useCallback((emailId: string, read: boolean) => {
setEmails(prev => prev.map(e => e.id === emailId ? { ...e, isRead: read } : e))
}, [])
const handleToggleStar = useCallback((emailId: string, starred: boolean) => {
setEmails(prev => prev.map(e => e.id === emailId ? { ...e, isStarred: starred } : e))
}, [])
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,
preview: data.body.slice(0, 100),
receivedAt: Date.now(),
isRead: true,
isStarred: false,
body: data.body,
}
setEmails(prev => [newEmail, ...prev])
setShowCompose(false)
}, [])
const handleNavigateFolder = useCallback((folderId: string) => {
setActiveFolder(folderId)
setSelectedEmailId(null)
}, [])
const unreadCount = filteredEmails.filter(e => !e.isRead).length
const { state, actions } = useEmailClient()
const header = (
<MailboxHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isDarkMode={isDarkMode}
searchQuery={state.searchQuery}
onSearchChange={actions.setSearchQuery}
isDarkMode={state.isDarkMode}
onToggleTheme={() => {
setIsDarkMode(prev => !prev)
document.documentElement.setAttribute('data-theme', isDarkMode ? 'light' : 'dark')
actions.setIsDarkMode(prev => !prev)
document.documentElement.setAttribute(
'data-theme',
state.isDarkMode ? 'light' : 'dark'
)
}}
onMenuClick={() => setSidebarOpen(prev => !prev)}
onMenuClick={() =>
actions.setSidebarOpen(prev => !prev)
}
onSettingsClick={() => {}}
onAlertsClick={() => {}}
alertCount={unreadCount}
alertCount={state.unreadCount}
locale="EN"
onCycleLocale={() => {}}
/>
@@ -199,52 +40,69 @@ export default function EmailClientContent() {
const sidebar = (
<MailboxSidebar
folders={folders}
onNavigate={handleNavigateFolder}
onCompose={() => setShowCompose(true)}
folders={state.folders}
onNavigate={actions.handleNavigateFolder}
onCompose={() => actions.setShowCompose(true)}
/>
)
const main = (
<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
variant="body2"
className="mailbox-thread-folder-label"
>
{state.activeFolder}
{state.filteredEmails.length > 0 &&
` (${state.filteredEmails.length})`}
</Typography>
<Typography variant="caption" className="mailbox-thread-unread-label">
{unreadCount} unread
<Typography
variant="caption"
className="mailbox-thread-unread-label"
>
{state.unreadCount} unread
</Typography>
</Box>
{filteredEmails.length === 0 ? (
{state.filteredEmails.length === 0 ? (
<Box className="mailbox-empty-state">
<span className="mailbox-empty-icon">
{activeFolder === 'starred' ? '⭐' : activeFolder === 'trash' ? '🗑️' : '📭'}
</span>
<Typography variant="body2">
{activeFolder === 'inbox' && searchQuery ? 'No results found' : `No messages in ${activeFolder}`}
{state.activeFolder === 'inbox' &&
state.searchQuery
? 'No results found'
: `No messages in ${state.activeFolder}`}
</Typography>
</Box>
) : (
<ThreadList
emails={filteredEmails}
selectedEmailId={selectedEmailId || undefined}
onSelectEmail={handleSelectEmail}
onToggleRead={handleToggleRead}
onToggleStar={handleToggleStar}
emails={state.filteredEmails}
selectedEmailId={
state.selectedEmailId || undefined
}
onSelectEmail={actions.handleSelectEmail}
onToggleRead={actions.handleToggleRead}
onToggleStar={actions.handleToggleStar}
/>
)}
</Box>
)
const detail = selectedEmail ? (
const detail = state.selectedEmail ? (
<EmailDetail
email={selectedEmail}
onClose={() => setSelectedEmailId(null)}
email={state.selectedEmail}
onClose={() =>
actions.setSelectedEmailId(null)
}
onArchive={() => {}}
onDelete={() => {}}
onReply={() => setShowCompose(true)}
onForward={() => setShowCompose(true)}
onToggleStar={(starred) => handleToggleStar(selectedEmail.id, starred)}
onReply={() => actions.setShowCompose(true)}
onForward={() => actions.setShowCompose(true)}
onToggleStar={(starred) =>
actions.handleToggleStar(
state.selectedEmail!.id,
starred
)
}
/>
) : undefined
@@ -256,10 +114,12 @@ export default function EmailClientContent() {
main={main}
detail={detail}
/>
{showCompose && (
{state.showCompose && (
<ComposeWindow
onSend={handleSend}
onClose={() => setShowCompose(false)}
onSend={actions.handleSend}
onClose={() =>
actions.setShowCompose(false)
}
/>
)}
</>

View File

@@ -0,0 +1,178 @@
import type {
FolderNavigationItem,
} from '@metabuilder/fakemui/email'
export const DEMO_FOLDERS: FolderNavigationItem[] = [
{
id: 'inbox',
label: 'Inbox',
icon: 'inbox',
unreadCount: 3,
isActive: true,
},
{ id: 'starred', label: 'Starred', icon: 'star' },
{ id: 'sent', label: 'Sent', icon: 'send' },
{
id: 'drafts',
label: 'Drafts',
icon: 'drafts',
unreadCount: 1,
},
{ id: 'spam', label: 'Spam', icon: 'report' },
{ id: 'trash', label: 'Trash', icon: 'delete' },
]
const now = Date.now()
const hour = 3600000
const day = 86400000
export const DEMO_EMAILS = [
{
id: '1',
testId: '1',
from: 'Alice Chen',
to: ['you@metabuilder.io'],
subject: 'Sprint planning for next week',
preview:
'Hey team, I\'ve put together the backlog' +
' items we need to discuss in our sprint' +
' planning session...',
receivedAt: now - 2 * hour,
isRead: false,
isStarred: true,
body:
'Hey team,\n\nI\'ve put together the backlog' +
' items we need to discuss in our sprint' +
' planning session on Monday. Please review' +
' the board before the meeting.\n\n' +
'Key items:\n' +
'- DBAL multi-adapter support\n' +
'- Email client backend integration\n' +
'- Workflow engine performance improvements' +
'\n\nLet me know if you have anything to add.' +
'\n\nBest,\nAlice',
},
{
id: '2',
testId: '2',
from: 'GitHub',
to: ['you@metabuilder.io'],
subject:
'[metabuilder] PR #847: Fix loadFromDirectory' +
' tenantId auto-add',
preview:
'mergify[bot] merged pull request #847.' +
' loadFromDirectory() was missing the' +
' tenantId field...',
receivedAt: now - 5 * hour,
isRead: false,
isStarred: false,
body:
'mergify[bot] merged pull request #847.\n\n' +
'loadFromDirectory() was missing the tenantId' +
' field auto-add logic that loadFromFile()' +
' already had, causing "Unknown field:' +
' tenantId" on all entities using the' +
' shorthand convention.\n\n' +
'Files changed: 2\n' +
'Additions: 51\nDeletions: 22',
},
{
id: '3',
testId: '3',
from: 'Bob Martinez',
to: ['you@metabuilder.io'],
cc: ['alice@metabuilder.io'],
subject: 'Re: Database adapter benchmarks',
preview:
'The PostgreSQL adapter is showing 3x' +
' throughput improvement with the new' +
' connection pool settings...',
receivedAt: now - 1 * day,
isRead: false,
isStarred: false,
body:
'The PostgreSQL adapter is showing 3x' +
' throughput improvement with the new' +
' connection pool settings. Here are the' +
' results:\n\n' +
'- SQLite: 12,400 ops/sec (dev baseline)\n' +
'- PostgreSQL: 8,900 ops/sec -> 26,700' +
' ops/sec\n' +
'- MySQL: 7,200 ops/sec (unchanged)\n\n' +
'I\'ll push the config changes to the' +
' deployment branch.\n\nBob',
},
{
id: '4',
testId: '4',
from: 'Docker Hub',
to: ['you@metabuilder.io'],
subject: 'Build succeeded: metabuilder/dbal:latest',
preview:
'Your automated build for metabuilder/dbal' +
' has completed successfully. Image size:' +
' 487MB...',
receivedAt: now - 1 * day - 3 * hour,
isRead: true,
isStarred: false,
body:
'Your automated build for metabuilder/dbal' +
' has completed successfully.\n\n' +
'Image: metabuilder/dbal:latest\n' +
'Size: 487MB\nBuild time: 4m 23s\n' +
'Layers: 12',
},
{
id: '5',
testId: '5',
from: 'Carol Wu',
to: ['you@metabuilder.io'],
subject: 'FakeMUI component audit results',
preview:
'Finished the component audit - 167' +
' components across 11 categories. All' +
' tests passing...',
receivedAt: now - 2 * day,
isRead: true,
isStarred: true,
body:
'Finished the component audit - 167' +
' components across 11 categories. All' +
' tests passing.\n\n' +
'Breakdown:\n' +
'- Core React: 145 components\n' +
'- Email: 22 components\n' +
'- Icons: 421\n' +
'- SCSS modules: 78\n\n' +
'The QML ports are at 104 components.' +
' Python bindings cover 15 of the most-used' +
' ones.\n\nCarol',
},
{
id: '6',
testId: '6',
from: 'Sentry',
to: ['you@metabuilder.io'],
subject:
'New issue: DBAL fetch failed: 422' +
' Unprocessable Entity',
preview:
'A new error was detected in' +
' codegen-frontend. fetchFromDBAL threw an' +
' error for unknown entity...',
receivedAt: now - 3 * day,
isRead: true,
isStarred: false,
body:
'A new error was detected in' +
' codegen-frontend.\n\n' +
'Error: DBAL fetch failed: 422' +
' Unprocessable Entity\n' +
'File: src/store/middleware/dbalSync.ts:131\n' +
'Occurrences: 47\nUsers affected: 3\n\n' +
'Stack trace:\n' +
'fetchFromDBAL @ dbalSync.ts:131\n' +
'loadSettings @ layout.tsx:33',
},
]

View File

@@ -0,0 +1,149 @@
'use client'
import { useState, useCallback } from 'react'
import {
DEMO_EMAILS,
DEMO_FOLDERS,
} from '../data/demo-emails'
export function useEmailClient() {
const [activeFolder, setActiveFolder] =
useState('inbox')
const [selectedEmailId, setSelectedEmailId] =
useState<string | null>(null)
const [emails, setEmails] = useState(DEMO_EMAILS)
const [showCompose, setShowCompose] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isDarkMode, setIsDarkMode] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(true)
const folders = DEMO_FOLDERS.map(f => ({
...f,
isActive: f.id === activeFolder,
}))
const selectedEmail =
emails.find(e => e.id === selectedEmailId) || null
const filteredEmails = emails.filter(e => {
if (activeFolder === 'starred') return e.isStarred
if (activeFolder === 'sent') return false
if (activeFolder === 'drafts') return false
if (activeFolder === 'spam') return false
if (activeFolder === 'trash') return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
return (
e.from.toLowerCase().includes(q) ||
e.subject.toLowerCase().includes(q) ||
e.preview.toLowerCase().includes(q)
)
}
return true
})
const unreadCount =
filteredEmails.filter(e => !e.isRead).length
const handleSelectEmail = useCallback(
(emailId: string) => {
setSelectedEmailId(emailId)
setEmails(prev =>
prev.map(e =>
e.id === emailId
? { ...e, isRead: true }
: e
)
)
},
[]
)
const handleToggleRead = useCallback(
(emailId: string, read: boolean) => {
setEmails(prev =>
prev.map(e =>
e.id === emailId
? { ...e, isRead: read }
: e
)
)
},
[]
)
const handleToggleStar = useCallback(
(emailId: string, starred: boolean) => {
setEmails(prev =>
prev.map(e =>
e.id === emailId
? { ...e, isStarred: starred }
: e
)
)
},
[]
)
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,
preview: data.body.slice(0, 100),
receivedAt: Date.now(),
isRead: true,
isStarred: false,
body: data.body,
}
setEmails(prev => [newEmail, ...prev])
setShowCompose(false)
},
[]
)
const handleNavigateFolder = useCallback(
(folderId: string) => {
setActiveFolder(folderId)
setSelectedEmailId(null)
},
[]
)
return {
state: {
activeFolder,
selectedEmailId,
emails,
showCompose,
searchQuery,
isDarkMode,
sidebarOpen,
folders,
selectedEmail,
filteredEmails,
unreadCount,
},
actions: {
setSelectedEmailId,
setSearchQuery,
setShowCompose,
setIsDarkMode,
setSidebarOpen,
handleSelectEmail,
handleToggleRead,
handleToggleStar,
handleSend,
handleNavigateFolder,
},
}
}

View File

@@ -10,3 +10,4 @@ export { useMailboxes, type UseMailboxesResult, type Folder } from './useMailbox
export { useAccounts, type UseAccountsResult, type EmailAccount } from './useAccounts'
export { useCompose, type UseComposeResult, type EmailDraft } from './useCompose'
export { useMessages, type UseMessagesResult, type Message } from './useMessages'
export { useKeyboardShortcuts, type KeyboardShortcutMap } from './useKeyboardShortcuts'

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react'
/**
* Map of shortcut keys to handler functions.
* Use '#' for Shift+3, '?' for Shift+/, etc.
*/
export interface KeyboardShortcutMap {
[key: string]: () => void
}
const IGNORED_TAGS = new Set([
'INPUT', 'TEXTAREA', 'SELECT',
])
function isEditable(el: EventTarget | null): boolean {
if (!el || !(el instanceof HTMLElement)) return false
if (IGNORED_TAGS.has(el.tagName)) return true
return el.isContentEditable
}
/**
* Gmail-style keyboard shortcuts hook.
* Fires mapped actions on keydown unless an
* input/textarea/contenteditable is focused.
* @param shortcuts - key-to-action map
* @param enabled - toggle listener (default true)
*/
export function useKeyboardShortcuts(
shortcuts: KeyboardShortcutMap,
enabled = true
): void {
useEffect(() => {
if (!enabled) return
const handler = (e: KeyboardEvent) => {
if (isEditable(e.target)) return
const action = shortcuts[e.key]
if (action) {
e.preventDefault()
action()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [shortcuts, enabled])
}