mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
178
frontends/emailclient/app/data/demo-emails.ts
Normal file
178
frontends/emailclient/app/data/demo-emails.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
149
frontends/emailclient/app/hooks/useEmailClient.ts
Normal file
149
frontends/emailclient/app/hooks/useEmailClient.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
47
hooks/email/useKeyboardShortcuts.ts
Normal file
47
hooks/email/useKeyboardShortcuts.ts
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user