[story #119] Frontend: Mailbox Owner Dashboard component (INB-1.5-A-S4)

- MailboxDashboard component with email list, search/filter, detail view
- Type-safe API adapter for mailbox endpoints (listMessages, getMessage, patchStatus)
- MailboxApiError class for structured error handling
- Client-side observability hooks (useErrorReporting, usePerformanceMetrics)
- 16 unit tests covering rendering, interactions, accessibility, error states
- Storybook stories with a11y addon enabled (4 stories: Default, WithDetail, EmptyState, WithPagination)
- Conforms to UX spec (#118) and API contract (#116)
- Reuses design-system tokens, AppShell, SidebarNav, StatusBadge components
- WCAG 2.2 Level AA: role=list, aria-label, aria-current, keyboard nav, focus-visible
- Responsive: three-column (lg), two-column (md), single-column (sm)
- Design-system lint clean: no hardcoded colors, all tokens/var() references

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-06-10 23:49:21 +00:00
committed by Paperclip
parent 8108be2871
commit fa946fbc2e
31 changed files with 3557 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
export { MailboxApiError } from "./mailbox";
export type {
MessageSummary,
MessageList,
MessageDetail,
MailboxListParams,
ApiError,
MessageStatusUpdate,
} from "./mailbox";
export {
listMailboxMessages,
getMailboxMessage,
patchMessageStatus,
} from "./mailbox";
+108
View File
@@ -0,0 +1,108 @@
const API_BASE = "/api/v1";
export interface MessageSummary {
message_id: string;
subject: string;
from_address: string;
from_name: string | null;
date: string;
body_preview: string | null;
modules: ("finance" | "crm" | "intel" | "promotions")[];
status: "new" | "processed" | "archived";
raw_s3_link: string | null;
}
export interface MessageList {
data: MessageSummary[];
pagination: {
limit: number;
next_cursor: string | null;
has_more: boolean;
};
}
export interface MessageDetail extends MessageSummary {
extracts: Record<string, Record<string, unknown>> | null;
papra_link: string | null;
}
export interface MailboxListParams {
q?: string;
module?: ("finance" | "crm" | "intel" | "promotions")[];
status?: ("new" | "processed" | "archived")[];
date_from?: string;
date_to?: string;
sort?: "date" | "-date" | "subject" | "-subject" | "from" | "-from";
limit?: number;
cursor?: string;
}
export interface ApiError {
error: string;
code: string;
details?: Record<string, unknown>;
}
export type MessageStatusUpdate = "processed" | "archived";
async function request<T>(path: string, options?: globalThis.RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!response.ok) {
const errorBody: ApiError = await response.json().catch(() => ({
error: response.statusText,
code: String(response.status),
}));
throw new MailboxApiError(response.status, errorBody);
}
return response.json();
}
export class MailboxApiError extends Error {
constructor(
public readonly status: number,
public readonly body: ApiError
) {
super(body.error || `Mailbox API error: ${status}`);
this.name = "MailboxApiError";
}
}
export function listMailboxMessages(params?: MailboxListParams): Promise<MessageList> {
const searchParams = new URLSearchParams();
if (params?.q) searchParams.set("q", params.q);
if (params?.module) {
for (const m of params.module) searchParams.append("module", m);
}
if (params?.status) {
for (const s of params.status) searchParams.append("status", s);
}
if (params?.date_from) searchParams.set("date_from", params.date_from);
if (params?.date_to) searchParams.set("date_to", params.date_to);
if (params?.sort) searchParams.set("sort", params.sort);
if (params?.limit) searchParams.set("limit", String(params.limit));
if (params?.cursor) searchParams.set("cursor", params.cursor);
const qs = searchParams.toString();
return request<MessageList>(`/mailbox/messages${qs ? `?${qs}` : ""}`);
}
export function getMailboxMessage(messageId: string): Promise<MessageDetail> {
return request<MessageDetail>(`/mailbox/messages/${encodeURIComponent(messageId)}`);
}
export function patchMessageStatus(
messageId: string,
status: MessageStatusUpdate
): Promise<MessageDetail> {
return request<MessageDetail>(`/mailbox/messages/${encodeURIComponent(messageId)}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
}
+215
View File
@@ -0,0 +1,215 @@
@layer components {
.status-message {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--border-radius-md);
border: 1px solid transparent;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
.status-message--success {
background-color: var(--color-semantic-success-light);
border-color: var(--color-semantic-success);
color: var(--color-neutral-gray-900);
}
.status-message--error {
background-color: var(--color-semantic-error-light);
border-color: var(--color-semantic-error);
color: var(--color-neutral-gray-900);
}
.status-message--warning {
background-color: var(--color-semantic-warning-light);
border-color: var(--color-semantic-warning);
color: var(--color-neutral-gray-900);
}
.status-message--info {
background-color: var(--color-brand-primary-light);
border-color: var(--color-brand-primary);
color: var(--color-neutral-gray-900);
}
.status-message__icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing-5);
height: var(--spacing-5);
}
.status-message__content {
flex: 1;
min-width: 0;
}
.status-message__title {
font-weight: var(--font-weight-semibold);
margin: 0;
}
.status-message__description {
color: var(--color-neutral-gray-600);
margin: var(--spacing-1) 0 0;
font-size: var(--font-size-sm);
}
.status-message__dismiss {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-xl);
line-height: var(--line-height-xl);
color: var(--color-neutral-gray-500);
padding: var(--spacing-0_5);
border-radius: var(--border-radius-sm);
transition: color var(--transition-fast), background-color var(--transition-fast);
}
.status-message__dismiss:hover {
color: var(--color-neutral-gray-700);
background-color: var(--color-neutral-gray-100);
}
.status-message__dismiss:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: var(--spacing-8);
color: var(--color-neutral-gray-500);
font-family: var(--font-family-sans);
font-size: var(--font-size-sm);
}
.loading__spinner {
border-radius: var(--border-radius-full);
border: 2px solid var(--color-neutral-gray-200);
border-top-color: var(--color-brand-primary);
animation: loading-spin 0.8s linear infinite;
}
.loading--sm .loading__spinner {
width: var(--spacing-4);
height: var(--spacing-4);
}
.loading--md .loading__spinner {
width: var(--spacing-8);
height: var(--spacing-8);
}
.loading--lg .loading__spinner {
width: var(--spacing-12);
height: var(--spacing-12);
}
.loading__label {
font-size: var(--font-size-sm);
color: var(--color-neutral-gray-500);
}
@keyframes loading-spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--color-neutral-gray-500);
font-family: var(--font-family-sans);
}
.empty-state__icon {
width: var(--spacing-12);
height: var(--spacing-12);
color: var(--color-neutral-gray-400);
}
.empty-state__title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-neutral-gray-700);
margin: 0;
}
.empty-state__description {
font-size: var(--font-size-sm);
color: var(--color-neutral-gray-500);
margin: 0;
max-width: 400px;
}
.empty-state__action {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
background-color: var(--color-brand-primary);
color: var(--color-brand-primary-on);
border: none;
border-radius: var(--border-radius-default);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.empty-state__action:hover {
background-color: var(--color-brand-primary-hover);
}
.empty-state__action:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-0_5) var(--spacing-2);
border-radius: var(--border-radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-xs);
text-transform: capitalize;
}
.status-badge--inbox {
background-color: var(--color-brand-primary-light);
color: var(--color-brand-primary);
}
.status-badge--archived {
background-color: var(--color-neutral-gray-100);
color: var(--color-neutral-gray-500);
}
.status-badge--processed {
background-color: var(--color-semantic-success-light);
color: var(--color-semantic-success);
}
.status-badge--error {
background-color: var(--color-semantic-error-light);
color: var(--color-semantic-error);
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
StatusMessage,
Loading,
EmptyState,
StatusBadge,
} from "./StatusMessage";
import "./StatusMessage.css";
import "../design-system/tokens.css";
const meta: Meta<typeof StatusMessage> = {
title: "Feedback/StatusMessage",
component: StatusMessage,
parameters: {
a11y: {
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "button-name", enabled: true },
],
},
},
},
tags: ["autodocs"],
};
export default meta;
export const Success: StoryObj<typeof StatusMessage> = {
args: {
variant: "success",
title: "Email archived successfully",
description: "The email has been moved to your archive.",
},
};
export const Error: StoryObj<typeof StatusMessage> = {
args: {
variant: "error",
title: "Failed to process email",
description: "An error occurred while processing. Please try again.",
},
};
export const Warning: StoryObj<typeof StatusMessage> = {
args: {
variant: "warning",
title: "Rate limit approaching",
description: "You have processed 45 out of 50 emails this hour.",
},
};
export const Info: StoryObj<typeof StatusMessage> = {
args: {
variant: "info",
title: "System update scheduled",
description: "Maintenance will occur at 2:00 AM UTC.",
},
};
export const Dismissable: StoryObj<typeof StatusMessage> = {
args: {
variant: "success",
title: "Changes saved",
onDismiss: () => alert("Dismissed"),
},
};
const LoadingMeta: Meta<typeof Loading> = {
title: "Feedback/Loading",
component: Loading,
tags: ["autodocs"],
};
export const LoadingDefault: StoryObj<typeof Loading> = {
args: {
size: "md",
label: "Loading",
},
};
export const LoadingSmall: StoryObj<typeof Loading> = {
args: {
size: "sm",
label: "Loading emails...",
},
};
export const LoadingLarge: StoryObj<typeof Loading> = {
args: {
size: "lg",
label: "Fetching inbox...",
},
};
const EmptyStateMeta: Meta<typeof EmptyState> = {
title: "Feedback/EmptyState",
component: EmptyState,
tags: ["autodocs"],
};
export const NoEmails: StoryObj<typeof EmptyState> = {
args: {
title: "No emails yet",
description: "New mail is checked every 5 minutes.",
},
};
export const NoSearchResults: StoryObj<typeof EmptyState> = {
args: {
title: "No emails match your search",
action: { label: "Clear search", onClick: () => alert("Search cleared") },
},
};
export const AllProcessed: StoryObj<typeof EmptyState> = {
args: {
title: "All caught up!",
description: "No inbox items remaining.",
},
};
const StatusBadgeMeta: Meta<typeof StatusBadge> = {
title: "Feedback/StatusBadge",
component: StatusBadge,
tags: ["autodocs"],
};
export const BadgeInbox: StoryObj<typeof StatusBadge> = {
args: { status: "inbox" },
};
export const BadgeArchived: StoryObj<typeof StatusBadge> = {
args: { status: "archived" },
};
export const BadgeProcessed: StoryObj<typeof StatusBadge> = {
args: { status: "processed" },
};
export const BadgeError: StoryObj<typeof StatusBadge> = {
args: { status: "error" },
};
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import {
StatusMessage,
Loading,
EmptyState,
StatusBadge,
} from "./StatusMessage";
describe("StatusMessage", () => {
it("renders success variant", () => {
render(
<StatusMessage variant="success" title="Saved successfully" />
);
expect(screen.getByText("Saved successfully")).toBeInTheDocument();
});
it("renders error variant with role=alert", () => {
render(
<StatusMessage variant="error" title="Something went wrong" />
);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("renders description when provided", () => {
render(
<StatusMessage
variant="info"
title="Heads up"
description="This is additional context"
/>
);
expect(screen.getByText("This is additional context")).toBeInTheDocument();
});
it("renders dismiss button when onDismiss is provided", () => {
const onDismiss = vi.fn();
render(
<StatusMessage variant="warning" title="Warning" onDismiss={onDismiss} />
);
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
expect(dismissButton).toBeInTheDocument();
fireEvent.click(dismissButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("does not render dismiss button when onDismiss is not provided", () => {
render(<StatusMessage variant="success" title="Done" />);
expect(screen.queryByRole("button", { name: /dismiss/i })).not.toBeInTheDocument();
});
it("applies correct aria-label for each variant", () => {
const { rerender } = render(
<StatusMessage variant="success" title="OK" />
);
expect(screen.getByLabelText("success: OK")).toBeInTheDocument();
rerender(<StatusMessage variant="error" title="Fail" />);
expect(screen.getByLabelText("error: Fail")).toBeInTheDocument();
rerender(<StatusMessage variant="warning" title="Careful" />);
expect(screen.getByLabelText("warning: Careful")).toBeInTheDocument();
rerender(<StatusMessage variant="info" title="Note" />);
expect(screen.getByLabelText("info: Note")).toBeInTheDocument();
});
});
describe("Loading", () => {
it("renders with default size and label", () => {
render(<Loading />);
expect(screen.getByRole("status")).toBeInTheDocument();
expect(screen.getByText("Loading")).toBeInTheDocument();
});
it("renders custom label", () => {
render(<Loading label="Fetching emails..." />);
expect(screen.getByText("Fetching emails...")).toBeInTheDocument();
});
it("renders with aria-label for accessibility", () => {
render(<Loading label="Loading data" />);
expect(screen.getByRole("status")).toHaveAttribute("aria-label", "Loading data");
});
it("applies size class variants", () => {
const { rerender } = render(<Loading size="sm" />);
expect(document.querySelector(".loading--sm")).toBeInTheDocument();
rerender(<Loading size="md" />);
expect(document.querySelector(".loading--md")).toBeInTheDocument();
rerender(<Loading size="lg" />);
expect(document.querySelector(".loading--lg")).toBeInTheDocument();
});
});
describe("EmptyState", () => {
it("renders title and description", () => {
render(
<EmptyState title="No emails" description="New mail is checked every 5 minutes." />
);
expect(screen.getByText("No emails")).toBeInTheDocument();
expect(screen.getByText("New mail is checked every 5 minutes.")).toBeInTheDocument();
});
it("renders action button when provided", () => {
const onClick = vi.fn();
render(
<EmptyState title="No results" action={{ label: "Clear search", onClick }} />
);
const button = screen.getByRole("button", { name: /clear search/i });
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("does not render action button when not provided", () => {
render(<EmptyState title="No data" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders with role=status", () => {
render(<EmptyState title="Empty" />);
expect(screen.getByRole("status")).toBeInTheDocument();
});
});
describe("StatusBadge", () => {
it("renders inbox badge", () => {
render(<StatusBadge status="inbox" />);
expect(screen.getByText("inbox")).toBeInTheDocument();
expect(screen.getByLabelText("Status: inbox")).toBeInTheDocument();
});
it("renders all status variants", () => {
const { rerender } = render(<StatusBadge status="inbox" />);
expect(screen.getByText("inbox")).toBeInTheDocument();
rerender(<StatusBadge status="archived" />);
expect(screen.getByText("archived")).toBeInTheDocument();
rerender(<StatusBadge status="processed" />);
expect(screen.getByText("processed")).toBeInTheDocument();
rerender(<StatusBadge status="error" />);
expect(screen.getByText("error")).toBeInTheDocument();
});
});
+142
View File
@@ -0,0 +1,142 @@
import { useState, useEffect, useCallback } from "react";
export interface StatusMessageProps {
variant: "success" | "error" | "warning" | "info";
title: string;
description?: string;
icon?: React.ReactNode;
onDismiss?: () => void;
className?: string;
}
const variantToAriaRole: Record<string, string> = {
success: "status",
error: "alert",
warning: "alert",
info: "status",
};
export function StatusMessage({
variant,
title,
description,
icon,
onDismiss,
className,
}: StatusMessageProps) {
return (
<div
className={["status-message", `status-message--${variant}`, className]
.filter(Boolean)
.join(" ")}
role={variantToAriaRole[variant] ?? "status"}
aria-label={`${variant}: ${title}`}
>
{icon && (
<span className="status-message__icon" aria-hidden="true">
{icon}
</span>
)}
<div className="status-message__content">
<p className="status-message__title">{title}</p>
{description && (
<p className="status-message__description">{description}</p>
)}
</div>
{onDismiss && (
<button
className="status-message__dismiss"
onClick={onDismiss}
aria-label="Dismiss message"
>
×
</button>
)}
</div>
);
}
export interface LoadingProps {
size?: "sm" | "md" | "lg";
label?: string;
className?: string;
}
export function Loading({
size = "md",
label = "Loading",
className,
}: LoadingProps) {
return (
<div
className={["loading", `loading--${size}`, className]
.filter(Boolean)
.join(" ")}
role="status"
aria-label={label}
>
<div className="loading__spinner" aria-hidden="true" />
<span className="loading__label">{label}</span>
</div>
);
}
export interface EmptyStateProps {
title: string;
description?: string;
action?: { label: string; onClick: () => void };
icon?: React.ReactNode;
className?: string;
}
export function EmptyState({
title,
description,
action,
icon,
className,
}: EmptyStateProps) {
return (
<div
className={["empty-state", className].filter(Boolean).join(" ")}
role="status"
>
{icon && (
<div className="empty-state__icon" aria-hidden="true">
{icon}
</div>
)}
<h3 className="empty-state__title">{title}</h3>
{description && (
<p className="empty-state__description">{description}</p>
)}
{action && (
<button
className="empty-state__action"
onClick={action.onClick}
type="button"
>
{action.label}
</button>
)}
</div>
);
}
export interface StatusBadgeProps {
status: "inbox" | "archived" | "processed" | "error";
className?: string;
}
export function StatusBadge({ status, className }: StatusBadgeProps) {
return (
<span
className={["status-badge", `status-badge--${status}`, className]
.filter(Boolean)
.join(" ")}
aria-label={`Status: ${status}`}
>
{status}
</span>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { StatusMessage, Loading, EmptyState, StatusBadge } from "./StatusMessage";
export type { StatusMessageProps, LoadingProps, EmptyStateProps, StatusBadgeProps } from "./StatusMessage";
+93
View File
@@ -0,0 +1,93 @@
@layer components {
.app-shell {
display: grid;
grid-template-columns: var(--spacing-16) 1fr;
grid-template-rows: 1fr;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: var(--color-surface-background);
font-family: var(--font-family-sans);
}
.app-shell__sidebar {
grid-column: 1;
grid-row: 1;
background-color: var(--color-surface-card);
border-right: 1px solid var(--color-neutral-gray-200);
overflow-y: auto;
padding: var(--spacing-4);
z-index: var(--z-index-sticky);
}
.app-shell__main {
grid-column: 2;
grid-row: 1;
overflow-y: auto;
padding: var(--spacing-4);
outline: none;
}
.app-shell__detail {
grid-column: 3;
grid-row: 1;
overflow-y: auto;
padding: var(--spacing-4);
border-left: 1px solid var(--color-neutral-gray-200);
background-color: var(--color-surface-card);
}
@media (min-width: 640px) and (max-width: 767px) {
.app-shell {
grid-template-columns: 1fr;
}
.app-shell__sidebar {
display: none;
}
.app-shell__detail {
display: none;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.app-shell {
grid-template-columns: 1fr 1fr;
}
.app-shell__sidebar {
display: none;
}
}
@media (min-width: 1024px) {
.app-shell {
grid-template-columns: var(--spacing-16) 1fr;
}
.app-shell--with-detail {
grid-template-columns: var(--spacing-16) 1fr 1fr;
}
}
@media (min-width: 1280px) {
.app-shell--with-detail {
grid-template-columns: var(--spacing-16) 2fr 3fr;
}
}
.skip-to-content {
position: absolute;
top: -100%;
left: var(--spacing-4);
z-index: var(--z-index-toast);
padding: var(--spacing-2) var(--spacing-4);
background-color: var(--color-brand-primary);
color: var(--color-brand-primary-on);
border-radius: var(--border-radius-default);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
text-decoration: none;
}
.skip-to-content:focus {
top: var(--spacing-4);
}
}
+123
View File
@@ -0,0 +1,123 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AppShell } from "./AppShell";
import { SidebarNav } from "../navigation/SidebarNav";
import type { NavItem } from "../navigation/SidebarNav";
import { StatusBadge } from "../feedback/StatusMessage";
import "./AppShell.css";
import "../navigation/SidebarNav.css";
import "../design-system/tokens.css";
const meta: Meta<typeof AppShell> = {
title: "Layout/AppShell",
component: AppShell,
parameters: {
a11y: {
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "aria-valid-attr-value", enabled: true },
],
},
},
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof AppShell>;
const inboxItems: NavItem[] = [
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: 5 },
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
{ id: "settings", label: "Settings", href: "/settings" },
{ id: "help", label: "Help", href: "/help" },
];
export const ThreeColumnLayout: Story = {
args: {
sidebar: (
<SidebarNav
items={inboxItems}
activeId="inbox"
onNavigate={() => {}}
/>
),
main: (
<div>
<h2 style={{ fontSize: "var(--font-size-2xl)", fontWeight: 600, margin: 0 }}>
Mailbox
</h2>
<p style={{ color: "var(--color-neutral-gray-500)", fontSize: "var(--font-size-sm)" }}>
5 unread messages
</p>
</div>
),
detail: (
<div>
<h3 style={{ fontSize: "var(--font-size-xl)", fontWeight: 600 }}>
Email Subject
</h3>
<p style={{ color: "var(--color-neutral-gray-500)", fontSize: "var(--font-size-sm)" }}>
From: sender@example.com · Jun 10
</p>
<p style={{ fontSize: "var(--font-size-base)", lineHeight: 1.5 }}>
Email body preview...
</p>
<div style={{ display: "flex", gap: "var(--spacing-2)", marginTop: "var(--spacing-4)" }}>
<button
style={{
backgroundColor: "var(--color-brand-primary)",
color: "var(--color-brand-primary-on)",
border: "none",
borderRadius: "var(--border-radius-default)",
padding: "var(--spacing-2) var(--spacing-4)",
fontSize: "var(--font-size-sm)",
fontWeight: 500,
cursor: "pointer",
}}
>
Archive
</button>
<button
style={{
backgroundColor: "var(--color-neutral-gray-100)",
color: "var(--color-neutral-gray-700)",
border: "none",
borderRadius: "var(--border-radius-default)",
padding: "var(--spacing-2) var(--spacing-4)",
fontSize: "var(--font-size-sm)",
fontWeight: 500,
cursor: "pointer",
}}
>
Mark Processed
</button>
</div>
</div>
),
},
};
export const TwoColumnLayout: Story = {
args: {
sidebar: (
<SidebarNav
items={inboxItems}
activeId="inbox"
onNavigate={() => {}}
/>
),
main: (
<div>
<h2 style={{ fontSize: "var(--font-size-2xl)", fontWeight: 600, margin: 0 }}>
Dashboard
</h2>
<div style={{ display: "flex", gap: "var(--spacing-4)", marginTop: "var(--spacing-4)" }}>
<StatusBadge status="inbox" />
<StatusBadge status="processed" />
</div>
</div>
),
},
};
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import React from "react";
import { render, screen } from "@testing-library/react";
import { AppShell } from "./AppShell";
describe("AppShell", () => {
it("renders sidebar and main content", () => {
render(
<AppShell
sidebar={<nav data-testid="sidebar">Sidebar</nav>}
main={<div data-testid="main">Main content</div>}
/>
);
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
expect(screen.getByTestId("main")).toBeInTheDocument();
});
it("renders detail panel when provided", () => {
render(
<AppShell
sidebar={<nav>Sidebar</nav>}
main={<div>Main</div>}
detail={<div data-testid="detail">Detail</div>}
/>
);
expect(screen.getByTestId("detail")).toBeInTheDocument();
});
it("does not render detail panel when not provided", () => {
render(
<AppShell
sidebar={<nav>Sidebar</nav>}
main={<div>Main</div>}
/>
);
expect(screen.queryByLabelText("Detail panel")).not.toBeInTheDocument();
});
it("renders with role=application", () => {
render(
<AppShell
sidebar={<nav>Sidebar</nav>}
main={<div>Main</div>}
/>
);
expect(screen.getByRole("application")).toBeInTheDocument();
});
it("main content has id for skip-to-content", () => {
render(
<AppShell
sidebar={<nav>Sidebar</nav>}
main={<div>Main</div>}
/>
);
expect(document.getElementById("main-content")).toBeInTheDocument();
});
it("sidebar has role=complementary", () => {
render(
<AppShell
sidebar={<nav>Sidebar</nav>}
main={<div>Main</div>}
/>
);
expect(screen.getByRole("complementary")).toBeInTheDocument();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { breakpoints, spacing, zIndex } from "../design-system";
export interface AppShellProps {
sidebar: React.ReactNode;
main: React.ReactNode;
detail?: React.ReactNode;
className?: string;
}
export function AppShell({ sidebar, main, detail, className }: AppShellProps) {
return (
<div
className={["app-shell", className].filter(Boolean).join(" ")}
role="application"
>
<aside className="app-shell__sidebar" role="complementary">
{sidebar}
</aside>
<main className="app-shell__main" id="main-content" tabIndex={-1}>
{main}
</main>
{detail && (
<section className="app-shell__detail" aria-label="Detail panel">
{detail}
</section>
)}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
@layer components {
.responsive-grid {
width: 100%;
max-width: 1200px;
margin-inline: auto;
padding-inline: var(--spacing-4);
}
@media (min-width: 640px) {
.responsive-grid {
padding-inline: var(--spacing-4);
}
}
@media (min-width: 768px) {
.responsive-grid {
padding-inline: var(--spacing-6);
}
}
@media (min-width: 1024px) {
.responsive-grid {
padding-inline: var(--spacing-8);
}
}
@media (min-width: 1280px) {
.responsive-grid {
padding-inline: var(--spacing-8);
}
}
@media (max-width: 639px) {
.responsive-grid {
grid-template-columns: repeat(4, 1fr);
}
.responsive-grid__item {
grid-column: 1 / -1;
}
}
@media (min-width: 640px) and (max-width: 767px) {
.responsive-grid {
grid-template-columns: repeat(8, 1fr);
}
.responsive-grid__item--sm-1 { grid-column: span 1; }
.responsive-grid__item--sm-2 { grid-column: span 2; }
.responsive-grid__item--sm-3 { grid-column: span 3; }
.responsive-grid__item--sm-4 { grid-column: span 4; }
.responsive-grid__item--sm-6 { grid-column: span 6; }
.responsive-grid__item--sm-8 { grid-column: span 8; }
}
@media (min-width: 768px) and (max-width: 1023px) {
.responsive-grid {
grid-template-columns: repeat(12, 1fr);
}
.responsive-grid__item--md-1 { grid-column: span 1; }
.responsive-grid__item--md-2 { grid-column: span 2; }
.responsive-grid__item--md-3 { grid-column: span 3; }
.responsive-grid__item--md-4 { grid-column: span 4; }
.responsive-grid__item--md-6 { grid-column: span 6; }
.responsive-grid__item--md-8 { grid-column: span 8; }
.responsive-grid__item--md-12 { grid-column: span 12; }
}
@media (min-width: 1024px) {
.responsive-grid {
grid-template-columns: repeat(12, 1fr);
}
.responsive-grid__item--lg-1 { grid-column: span 1; }
.responsive-grid__item--lg-2 { grid-column: span 2; }
.responsive-grid__item--lg-3 { grid-column: span 3; }
.responsive-grid__item--lg-4 { grid-column: span 4; }
.responsive-grid__item--lg-6 { grid-column: span 6; }
.responsive-grid__item--lg-8 { grid-column: span 8; }
.responsive-grid__item--lg-12 { grid-column: span 12; }
}
@media (min-width: 1280px) {
.responsive-grid__item--xl-1 { grid-column: span 1; }
.responsive-grid__item--xl-2 { grid-column: span 2; }
.responsive-grid__item--xl-3 { grid-column: span 3; }
.responsive-grid__item--xl-4 { grid-column: span 4; }
.responsive-grid__item--xl-6 { grid-column: span 6; }
.responsive-grid__item--xl-8 { grid-column: span 8; }
.responsive-grid__item--xl-12 { grid-column: span 12; }
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ResponsiveGrid, GridItem } from "./ResponsiveGrid";
import "./ResponsiveGrid.css";
import "../design-system/tokens.css";
const meta: Meta<typeof ResponsiveGrid> = {
title: "Layout/ResponsiveGrid",
component: ResponsiveGrid,
parameters: {
a11y: {
config: {
rules: [
{ id: "color-contrast", enabled: true },
],
},
},
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ResponsiveGrid>;
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--color-surface-card)",
borderRadius: "var(--border-radius-md)",
boxShadow: "var(--shadow-md)",
padding: "var(--spacing-4)",
minHeight: "120px",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "var(--font-size-sm)",
color: "var(--color-neutral-gray-600)",
fontWeight: "var(--font-weight-medium)",
};
export const Default12Column: Story = {
args: {
columns: 12,
gap: "var(--spacing-6)",
children: (
<>
<GridItem lgSpan={8}>
<div style={cardStyle}>Main content (8/12)</div>
</GridItem>
<GridItem lgSpan={4}>
<div style={cardStyle}>Sidebar (4/12)</div>
</GridItem>
</>
),
},
};
export const MetricCards: Story = {
args: {
columns: 12,
gap: "var(--spacing-6)",
children: (
<>
<GridItem mdSpan={6} lgSpan={3}>
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-brand-primary)" }}>
Ingestion Rate
</div>
</GridItem>
<GridItem mdSpan={6} lgSpan={3}>
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-semantic-success)" }}>
Error Count
</div>
</GridItem>
<GridItem mdSpan={6} lgSpan={3}>
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-semantic-warning)" }}>
Latency p99
</div>
</GridItem>
<GridItem mdSpan={6} lgSpan={3}>
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-semantic-info)" }}>
Uptime
</div>
</GridItem>
</>
),
},
};
export const EmailListAndDetail: Story = {
args: {
columns: 12,
gap: "var(--spacing-6)",
children: (
<>
<GridItem mdSpan={6} lgSpan={4}>
<div style={{ ...cardStyle, minHeight: "200px" }}>Email list</div>
</GridItem>
<GridItem mdSpan={6} lgSpan={8}>
<div style={{ ...cardStyle, minHeight: "200px" }}>Email detail</div>
</GridItem>
</>
),
},
};
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import React from "react";
import { render, screen } from "@testing-library/react";
import { ResponsiveGrid, GridItem } from "./ResponsiveGrid";
describe("ResponsiveGrid", () => {
it("renders children", () => {
render(
<ResponsiveGrid>
<div data-testid="child">Child</div>
</ResponsiveGrid>
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("applies default 12-column grid", () => {
render(
<ResponsiveGrid>
<div>Child</div>
</ResponsiveGrid>
);
const grid = document.querySelector(".responsive-grid") as HTMLElement;
expect(grid).toBeInTheDocument();
expect(grid.style.gridTemplateColumns).toBe("repeat(12, 1fr)");
});
it("applies custom column count", () => {
render(
<ResponsiveGrid columns={8}>
<div>Child</div>
</ResponsiveGrid>
);
const grid = document.querySelector(".responsive-grid") as HTMLElement;
expect(grid.style.gridTemplateColumns).toBe("repeat(8, 1fr)");
});
it("applies custom gap", () => {
render(
<ResponsiveGrid gap="var(--spacing-4)">
<div>Child</div>
</ResponsiveGrid>
);
const grid = document.querySelector(".responsive-grid") as HTMLElement;
expect(grid.style.gap).toBe("var(--spacing-4)");
});
it("supports custom element type via as prop", () => {
render(
<ResponsiveGrid as="section">
<div>Child</div>
</ResponsiveGrid>
);
expect(document.querySelector("section.responsive-grid")).toBeInTheDocument();
});
});
describe("GridItem", () => {
it("renders children", () => {
render(
<GridItem>
<div data-testid="item">Item</div>
</GridItem>
);
expect(screen.getByTestId("item")).toBeInTheDocument();
});
it("applies span style when span is provided", () => {
render(
<GridItem span={6}>
<div>Item</div>
</GridItem>
);
const item = document.querySelector(".responsive-grid__item") as HTMLElement;
expect(item.style.gridColumn).toBe("span 6");
});
it("applies responsive span classes", () => {
render(
<GridItem smSpan={4} mdSpan={6} lgSpan={8} xlSpan={12}>
<div>Item</div>
</GridItem>
);
const item = document.querySelector(".responsive-grid__item") as HTMLElement;
expect(item.classList.contains("responsive-grid__item--sm-4")).toBe(true);
expect(item.classList.contains("responsive-grid__item--md-6")).toBe(true);
expect(item.classList.contains("responsive-grid__item--lg-8")).toBe(true);
expect(item.classList.contains("responsive-grid__item--xl-12")).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { breakpoints } from "../design-system";
export interface ResponsiveGridProps {
children: React.ReactNode;
columns?: number;
gap?: string;
className?: string;
as?: keyof JSX.IntrinsicElements;
}
export function ResponsiveGrid({
children,
columns = 12,
gap = "var(--spacing-6)",
className,
as: Component = "div",
}: ResponsiveGridProps) {
return (
<Component
className={["responsive-grid", className].filter(Boolean).join(" ")}
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap,
}}
>
{children}
</Component>
);
}
export interface GridItemProps {
children: React.ReactNode;
span?: number;
smSpan?: number;
mdSpan?: number;
lgSpan?: number;
xlSpan?: number;
className?: string;
}
export function GridItem({
children,
span,
smSpan,
mdSpan,
lgSpan,
xlSpan,
className,
}: GridItemProps) {
return (
<div
className={[
"responsive-grid__item",
smSpan && `responsive-grid__item--sm-${smSpan}`,
mdSpan && `responsive-grid__item--md-${mdSpan}`,
lgSpan && `responsive-grid__item--lg-${lgSpan}`,
xlSpan && `responsive-grid__item--xl-${xlSpan}`,
className,
]
.filter(Boolean)
.join(" ")}
style={span ? { gridColumn: `span ${span}` } : undefined}
>
{children}
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { AppShell } from "./AppShell";
export type { AppShellProps } from "./AppShell";
export { ResponsiveGrid, GridItem } from "./ResponsiveGrid";
export type { ResponsiveGridProps, GridItemProps } from "./ResponsiveGrid";
+357
View File
@@ -0,0 +1,357 @@
@layer components {
.mailbox-dashboard {
display: flex;
flex-direction: column;
height: 100%;
font-family: var(--font-family-sans);
background-color: var(--color-surface-background);
}
.mailbox-dashboard__toolbar {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
border-bottom: 1px solid var(--color-neutral-gray-200);
background-color: var(--color-surface-card);
flex-shrink: 0;
}
.mailbox-dashboard__search {
flex: 1;
min-width: 0;
}
.mailbox-dashboard__filter-group {
display: flex;
gap: var(--spacing-2);
align-items: center;
flex-shrink: 0;
}
.mailbox-dashboard__filter-select {
appearance: none;
padding: var(--spacing-1) var(--spacing-3) var(--spacing-1) var(--spacing-2);
border: 1px solid var(--color-neutral-gray-300);
border-radius: var(--border-radius-default);
background-color: var(--color-surface-card);
color: var(--color-neutral-gray-700);
font-size: var(--font-size-sm);
font-family: var(--font-family-sans);
line-height: var(--line-height-base);
cursor: pointer;
}
.mailbox-dashboard__filter-select:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.mailbox-dashboard__filter-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-0_5) var(--spacing-2);
border-radius: var(--border-radius-full);
background-color: var(--color-brand-primary-light);
color: var(--color-brand-primary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
border: none;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.mailbox-dashboard__filter-chip:hover {
background-color: var(--color-brand-primary-hover);
color: var(--color-brand-primary-on);
}
.mailbox-dashboard__filter-chip:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.mailbox-dashboard__content {
flex: 1;
overflow-y: auto;
}
.mailbox-dashboard__list {
list-style: none;
padding: 0;
margin: 0;
}
.mailbox-dashboard__item {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-3) var(--spacing-4);
border-bottom: 1px solid var(--color-neutral-gray-100);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.mailbox-dashboard__item:hover {
background-color: var(--color-neutral-gray-50);
}
.mailbox-dashboard__item:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
.mailbox-dashboard__item--active {
background-color: var(--color-brand-primary-light);
}
.mailbox-dashboard__item-row {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.mailbox-dashboard__item-subject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-medium);
color: var(--color-neutral-gray-900);
font-size: var(--font-size-base);
}
.mailbox-dashboard__item-subject--unread {
font-weight: var(--font-weight-semibold);
}
.mailbox-dashboard__item-date {
flex-shrink: 0;
font-size: var(--font-size-xs);
color: var(--color-neutral-gray-500);
}
.mailbox-dashboard__item-meta {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.mailbox-dashboard__item-from {
font-size: var(--font-size-sm);
color: var(--color-neutral-gray-600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.mailbox-dashboard__item-preview {
font-size: var(--font-size-sm);
color: var(--color-neutral-gray-500);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.mailbox-dashboard__item-modules {
display: flex;
gap: var(--spacing-1);
flex-shrink: 0;
}
.mailbox-dashboard__module-badge {
padding: var(--spacing-0_5) var(--spacing-1);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: capitalize;
}
.mailbox-dashboard__module-badge--finance {
background-color: var(--color-semantic-success-light);
color: var(--color-semantic-success);
}
.mailbox-dashboard__module-badge--crm {
background-color: var(--color-brand-primary-light);
color: var(--color-brand-primary);
}
.mailbox-dashboard__module-badge--intel {
background-color: var(--color-semantic-warning-light);
color: var(--color-semantic-warning);
}
.mailbox-dashboard__module-badge--promotions {
background-color: var(--color-neutral-gray-100);
color: var(--color-neutral-gray-500);
}
.mailbox-dashboard__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
border-top: 1px solid var(--color-neutral-gray-200);
background-color: var(--color-surface-card);
}
.mailbox-dashboard__pagination-button {
padding: var(--spacing-1) var(--spacing-3);
border: 1px solid var(--color-neutral-gray-300);
border-radius: var(--border-radius-default);
background-color: var(--color-surface-card);
color: var(--color-neutral-gray-700);
font-size: var(--font-size-sm);
font-family: var(--font-family-sans);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.mailbox-dashboard__pagination-button:hover:not(:disabled) {
background-color: var(--color-neutral-gray-50);
border-color: var(--color-brand-primary);
}
.mailbox-dashboard__pagination-button:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.mailbox-dashboard__pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mailbox-dashboard__detail {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--spacing-4);
overflow-y: auto;
}
.mailbox-dashboard__detail-header {
margin-bottom: var(--spacing-4);
}
.mailbox-dashboard__detail-subject {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-neutral-gray-900);
margin: 0;
}
.mailbox-dashboard__detail-meta {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
font-size: var(--font-size-sm);
color: var(--color-neutral-gray-500);
}
.mailbox-dashboard__detail-body {
flex: 1;
font-size: var(--font-size-base);
line-height: var(--line-height-base);
color: var(--color-neutral-gray-800);
white-space: pre-wrap;
}
.mailbox-dashboard__detail-actions {
display: flex;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
padding-top: var(--spacing-4);
border-top: 1px solid var(--color-neutral-gray-200);
}
.mailbox-dashboard__action-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
border: none;
border-radius: var(--border-radius-default);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
font-family: var(--font-family-sans);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.mailbox-dashboard__action-button--primary {
background-color: var(--color-brand-primary);
color: var(--color-brand-primary-on);
}
.mailbox-dashboard__action-button--primary:hover {
background-color: var(--color-brand-primary-hover);
}
.mailbox-dashboard__action-button--primary:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.mailbox-dashboard__action-button--secondary {
background-color: var(--color-neutral-gray-100);
color: var(--color-neutral-gray-700);
}
.mailbox-dashboard__action-button--secondary:hover {
background-color: var(--color-neutral-gray-200);
}
.mailbox-dashboard__action-button--secondary:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.mailbox-dashboard__action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mailbox-dashboard__search-input {
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
border: 1px solid var(--color-neutral-gray-300);
border-radius: var(--border-radius-default);
font-size: var(--font-size-sm);
font-family: var(--font-family-sans);
color: var(--color-neutral-gray-800);
background-color: var(--color-surface-card);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.mailbox-dashboard__search-input:focus {
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
outline: none;
}
.mailbox-dashboard__search-input::placeholder {
color: var(--color-neutral-gray-400);
}
@media (max-width: 767px) {
.mailbox-dashboard__toolbar {
flex-wrap: wrap;
}
.mailbox-dashboard__filter-group {
width: 100%;
overflow-x: auto;
}
.mailbox-dashboard__detail {
padding: var(--spacing-3);
}
}
}
+145
View File
@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MailboxDashboard } from "./MailboxDashboard";
import type { MessageList, MessageDetail } from "../api/mailbox";
import "./MailboxDashboard.css";
import "../feedback/StatusMessage.css";
import "../design-system/tokens.css";
const mockMessages: MessageList = {
data: [
{
message_id: "msg-1",
subject: "Your June invoice from Acme Corp",
from_address: "billing@acme.example",
from_name: "Acme Billing",
date: "2026-06-10T09:42:00Z",
body_preview: "Invoice #INV-2042 for $120.00 due 2026-06-30...",
modules: ["finance"],
status: "new",
raw_s3_link: null,
},
{
message_id: "msg-2",
subject: "Weekly digest — June 7–13",
from_address: "noreply@example.com",
from_name: "Digest Bot",
date: "2026-06-09T08:00:00Z",
body_preview: "Here is your weekly summary of mailbox activity...",
modules: ["promotions"],
status: "processed",
raw_s3_link: null,
},
{
message_id: "msg-3",
subject: "CRM lead: New contact from website",
from_address: "crm-system@company.io",
from_name: null,
date: "2026-06-08T15:30:00Z",
body_preview: "A new lead has been captured from the contact form...",
modules: ["crm"],
status: "new",
raw_s3_link: null,
},
{
message_id: "msg-4",
subject: "Intelligence briefing #42",
from_address: "intel@secure.net",
from_name: "Intel Service",
date: "2026-06-07T12:00:00Z",
body_preview: "Key findings from the latest threat landscape analysis...",
modules: ["intel"],
status: "archived",
raw_s3_link: null,
},
],
pagination: { limit: 25, next_cursor: null, has_more: false },
};
const mockDetail: MessageDetail = {
...mockMessages.data[0],
extracts: {
finance: {
Direction: "expense",
Counterparty: "Acme Corp",
Amount: 120.0,
Currency: "USD",
},
},
papra_link: null,
};
const meta: Meta<typeof MailboxDashboard> = {
title: "Mailbox/MailboxDashboard",
component: MailboxDashboard,
parameters: {
a11y: {
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "aria-valid-attr-value", enabled: true },
{ id: "list", enabled: true },
{ id: "label", enabled: true },
],
},
},
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof MailboxDashboard>;
export const Default: Story = {
args: {},
parameters: {
mockData: [
{
url: "/api/v1/mailbox/messages*",
response: mockMessages,
},
],
},
};
export const WithDetail: Story = {
args: {},
parameters: {
mockData: [
{
url: "/api/v1/mailbox/messages*",
response: (url: string) => {
if (url.includes("/status")) return { ...mockDetail, status: "archived" };
if (url.match(/\/mailbox\/messages\/[^/]+$/)) return mockDetail;
return mockMessages;
},
},
],
},
};
export const EmptyState: Story = {
args: {},
parameters: {
mockData: [
{
url: "/api/v1/mailbox/messages*",
response: { data: [], pagination: { limit: 25, next_cursor: null, has_more: false } },
},
],
},
};
export const WithPagination: Story = {
args: {},
parameters: {
mockData: [
{
url: "/api/v1/mailbox/messages*",
response: {
...mockMessages,
pagination: { limit: 25, next_cursor: "cursor-abc", has_more: true },
},
},
],
},
};
+273
View File
@@ -0,0 +1,273 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MailboxDashboard } from "./MailboxDashboard";
import type { MessageList, MessageDetail } from "../api/mailbox";
const mockMessages: MessageList = {
data: [
{
message_id: "msg-1",
subject: "Your June invoice from Acme Corp",
from_address: "billing@acme.example",
from_name: "Acme Billing",
date: "2026-06-10T09:42:00Z",
body_preview: "Invoice #INV-2042 for $120.00 due 2026-06-30...",
modules: ["finance"],
status: "new",
raw_s3_link: null,
},
{
message_id: "msg-2",
subject: "Weekly digest",
from_address: "noreply@example.com",
from_name: null,
date: "2026-06-09T08:00:00Z",
body_preview: "Here is your weekly summary...",
modules: ["promotions"],
status: "processed",
raw_s3_link: null,
},
],
pagination: { limit: 25, next_cursor: null, has_more: false },
};
const mockDetail: MessageDetail = {
...mockMessages.data[0],
extracts: {
finance: {
Direction: "expense",
Counterparty: "Acme Corp",
Amount: 120.0,
Currency: "USD",
},
},
papra_link: null,
};
describe("MailboxDashboard", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("renders loading state initially", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(
() => new Promise(() => {})
);
render(<MailboxDashboard />);
expect(screen.getByText("Loading messages…")).toBeInTheDocument();
});
it("renders message list after fetch", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByText("Your June invoice from Acme Corp")).toBeInTheDocument();
});
});
it("renders empty state when no messages", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [], pagination: { limit: 25, next_cursor: null, has_more: false } }),
} as Response);
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByText("No messages")).toBeInTheDocument();
});
});
it("renders search input", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
expect(screen.getByLabelText("Search messages")).toBeInTheDocument();
});
it("renders filter by status dropdown", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
expect(screen.getByLabelText("Filter by status")).toBeInTheDocument();
});
it("renders sort dropdown", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
expect(screen.getByLabelText("Sort messages")).toBeInTheDocument();
});
it("renders module badges for finance", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByText("finance")).toBeInTheDocument();
});
});
it("renders status badges", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
await waitFor(() => {
const badges = screen.getAllByText("inbox");
expect(badges.length).toBeGreaterThanOrEqual(1);
});
});
it("fetches message detail on click", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation((url: string | URL | Request) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (urlStr.includes("/mailbox/messages/msg-1")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockDetail),
} as Response);
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
});
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByText("Your June invoice from Acme Corp")).toBeInTheDocument();
});
const button = screen.getByRole("button", { name: /Your June invoice from Acme Corp/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByRole("article")).toBeInTheDocument();
});
});
it("calls patchMessageStatus when Archive is clicked", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation((url: string | URL | Request) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (urlStr.includes("/mailbox/messages/msg-1/status")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ ...mockDetail, status: "archived" }),
} as Response);
}
if (urlStr.includes("/mailbox/messages/msg-1") && !urlStr.includes("/status")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockDetail),
} as Response);
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
});
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByText("Your June invoice from Acme Corp")).toBeInTheDocument();
});
const button = screen.getByRole("button", { name: /Your June invoice from Acme Corp/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText("Archive")).toBeInTheDocument();
});
const archiveButton = screen.getByText("Archive");
fireEvent.click(archiveButton);
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("/mailbox/messages/msg-1/status"),
expect.objectContaining({ method: "PATCH" })
);
});
});
it("has role=application for accessibility", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
expect(screen.getByRole("application")).toBeInTheDocument();
});
it("search input has aria-label", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
expect(screen.getByLabelText("Search messages")).toBeInTheDocument();
});
it("message list has role=list", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByRole("list", { name: "Email inbox" })).toBeInTheDocument();
});
});
it("shows error state on API failure", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: "Internal error", code: "INTERNAL_ERROR" }),
} as Response);
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByText("error", { exact: false })).toBeInTheDocument();
});
});
it("clears search on Escape key", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMessages),
} as Response);
render(<MailboxDashboard />);
const searchInput = screen.getByLabelText("Search messages");
fireEvent.change(searchInput, { target: { value: "test" } });
expect(searchInput).toHaveValue("test");
fireEvent.keyDown(searchInput, { key: "Escape" });
expect(searchInput).toHaveValue("");
});
it("renders load more button when has_more is true", async () => {
const mockWithMore: MessageList = {
data: mockMessages.data,
pagination: { limit: 25, next_cursor: "cursor-abc", has_more: true },
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockWithMore),
} as Response);
render(<MailboxDashboard />);
await waitFor(() => {
expect(screen.getByText("Load more")).toBeInTheDocument();
});
});
});
+332
View File
@@ -0,0 +1,332 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
import {
listMailboxMessages,
getMailboxMessage,
patchMessageStatus,
MailboxApiError,
} from "../api/mailbox";
import type { MessageSummary, MessageDetail, MailboxListParams, MessageStatusUpdate } from "../api/mailbox";
import { useErrorReporting, usePerformanceMetrics } from "../observability";
import { Loading, EmptyState, StatusBadge } from "../feedback/StatusMessage";
import "./MailboxDashboard.css";
export interface MailboxDashboardProps {
onNavigate?: (route: string) => void;
className?: string;
}
type FilterStatus = "all" | "new" | "processed" | "archived";
type SortOption = "-date" | "date" | "subject" | "-subject" | "from" | "-from";
export function MailboxDashboard({ onNavigate, className }: MailboxDashboardProps) {
const { reportError } = useErrorReporting("MailboxDashboard");
const { reportRenderTime } = usePerformanceMetrics("MailboxDashboard");
const [messages, setMessages] = useState<MessageSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<MessageDetail | null>(null);
const [loading, setLoading] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [filterStatus, setFilterStatus] = useState<FilterStatus>("all");
const [sort, setSort] = useState<SortOption>("-date");
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const listRef = useRef<HTMLUListElement>(null);
const fetchMessages = useCallback(
async (params?: Partial<MailboxListParams> & { append?: boolean }) => {
try {
setLoading(true);
setError(null);
const listParams: MailboxListParams = {
q: search || undefined,
status: filterStatus === "all" ? undefined : [filterStatus as "new" | "processed" | "archived"],
sort,
limit: 25,
cursor: params?.cursor || undefined,
...params,
};
const result = await listMailboxMessages(listParams);
if (params?.append) {
setMessages((prev) => [...prev, ...result.data]);
} else {
setMessages(result.data);
}
setNextCursor(result.pagination.next_cursor);
setHasMore(result.pagination.has_more);
reportRenderTime("fetch-messages");
} catch (err) {
if (err instanceof MailboxApiError) {
reportError(err, { code: err.body.code });
} else {
reportError(err instanceof Error ? err : new Error(String(err)));
}
setError(err instanceof Error ? err.message : "Failed to load messages");
} finally {
setLoading(false);
}
},
[search, filterStatus, sort, reportError, reportRenderTime]
);
useEffect(() => {
fetchMessages();
}, [filterStatus, sort]);
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
fetchMessages();
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [search]);
const handleSelectMessage = useCallback(
async (messageId: string) => {
setSelectedId(messageId);
setLoadingDetail(true);
try {
const result = await getMailboxMessage(messageId);
setDetail(result);
reportRenderTime("fetch-detail");
} catch (err) {
if (err instanceof MailboxApiError) {
reportError(err, { code: err.body.code });
} else {
reportError(err instanceof Error ? err : new Error(String(err)));
}
setDetail(null);
} finally {
setLoadingDetail(false);
}
},
[reportError, reportRenderTime]
);
const handleStatusUpdate = useCallback(
async (messageId: string, newStatus: MessageStatusUpdate) => {
try {
const result = await patchMessageStatus(messageId, newStatus);
setDetail(result);
setMessages((prev) =>
prev.map((m) => (m.message_id === messageId ? { ...m, status: newStatus } : m))
);
reportRenderTime(`status-update-${newStatus}`);
} catch (err) {
if (err instanceof MailboxApiError) {
reportError(err, { code: err.body.code });
} else {
reportError(err instanceof Error ? err : new Error(String(err)));
}
}
},
[reportError, reportRenderTime]
);
const handleLoadMore = useCallback(() => {
if (nextCursor) {
fetchMessages({ cursor: nextCursor, append: true });
}
}, [nextCursor, fetchMessages]);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
setSearch("");
(e.target as HTMLInputElement).blur();
}
},
[]
);
const sidebarItems = useMemo(
() => [
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: messages.filter((m) => m.status === "new").length },
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
],
[messages]
);
return (
<div
className={["mailbox-dashboard", className].filter(Boolean).join(" ")}
role="application"
aria-label="Mailbox Owner Dashboard"
>
<div className="mailbox-dashboard__toolbar" role="search">
<div className="mailbox-dashboard__search">
<input
type="search"
className="mailbox-dashboard__search-input"
placeholder="Search messages… (press / to focus)"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
aria-label="Search messages"
/>
</div>
<div className="mailbox-dashboard__filter-group">
<select
className="mailbox-dashboard__filter-select"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as FilterStatus)}
aria-label="Filter by status"
>
<option value="all">All</option>
<option value="new">Inbox</option>
<option value="archived">Archived</option>
<option value="processed">Processed</option>
</select>
<select
className="mailbox-dashboard__filter-select"
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
aria-label="Sort messages"
>
<option value="-date">Newest first</option>
<option value="date">Oldest first</option>
<option value="subject">Subject A–Z</option>
<option value="-subject">Subject Z–A</option>
<option value="from">From A–Z</option>
<option value="-from">From Z–A</option>
</select>
</div>
</div>
<div className="mailbox-dashboard__content">
{error && (
<StatusBadge status="error" />
)}
{loading && messages.length === 0 ? (
<Loading label="Loading messages…" />
) : messages.length === 0 ? (
<EmptyState
title="No messages"
description={search ? `No results for "${search}"` : "Your mailbox is empty"}
/>
) : (
<ul className="mailbox-dashboard__list" role="list" aria-label="Email inbox" ref={listRef}>
{messages.map((msg) => (
<li key={msg.message_id} className="mailbox-dashboard__list-item" role="listitem">
<button
className={[
"mailbox-dashboard__item",
selectedId === msg.message_id && "mailbox-dashboard__item--active",
]
.filter(Boolean)
.join(" ")}
onClick={() => handleSelectMessage(msg.message_id)}
aria-label={`${msg.subject} from ${msg.from_name || msg.from_address}, ${new Date(msg.date).toLocaleDateString()}`}
type="button"
>
<div className="mailbox-dashboard__item-row">
<span
className={[
"mailbox-dashboard__item-subject",
msg.status === "new" && "mailbox-dashboard__item-subject--unread",
]
.filter(Boolean)
.join(" ")}
>
{msg.subject || "(no subject)"}
</span>
<span className="mailbox-dashboard__item-date">
{new Date(msg.date).toLocaleDateString()}
</span>
</div>
<div className="mailbox-dashboard__item-meta">
<span className="mailbox-dashboard__item-from">
{msg.from_name || msg.from_address}
</span>
{msg.body_preview && (
<span className="mailbox-dashboard__item-preview">
{msg.body_preview}
</span>
)}
<span className="mailbox-dashboard__item-modules">
{msg.modules.map((mod) => (
<span
key={mod}
className={`mailbox-dashboard__module-badge mailbox-dashboard__module-badge--${mod}`}
>
{mod}
</span>
))}
</span>
<StatusBadge status={msg.status === "new" ? "inbox" : msg.status} />
</div>
</button>
</li>
))}
</ul>
)}
{hasMore && (
<div className="mailbox-dashboard__pagination">
<button
className="mailbox-dashboard__pagination-button"
onClick={handleLoadMore}
disabled={loading}
type="button"
>
Load more
</button>
</div>
)}
</div>
{selectedId && detail && (
<aside className="mailbox-dashboard__detail" aria-label="Email detail" role="article">
{loadingDetail ? (
<Loading label="Loading message…" />
) : (
<>
<div className="mailbox-dashboard__detail-header">
<h2 className="mailbox-dashboard__detail-subject">{detail.subject || "(no subject)"}</h2>
<div className="mailbox-dashboard__detail-meta">
<span>From: {detail.from_name || detail.from_address}</span>
<span>{new Date(detail.date).toLocaleString()}</span>
<StatusBadge status={detail.status === "new" ? "inbox" : detail.status} />
</div>
</div>
<div className="mailbox-dashboard__detail-body">{detail.body_preview}</div>
<div className="mailbox-dashboard__detail-actions">
{detail.status !== "archived" && (
<button
className="mailbox-dashboard__action-button mailbox-dashboard__action-button--primary"
onClick={() => handleStatusUpdate(detail.message_id, "archived")}
disabled={loading}
type="button"
>
Archive
</button>
)}
{detail.status !== "processed" && (
<button
className="mailbox-dashboard__action-button mailbox-dashboard__action-button--secondary"
onClick={() => handleStatusUpdate(detail.message_id, "processed")}
disabled={loading}
type="button"
>
Mark Processed
</button>
)}
</div>
</>
)}
</aside>
)}
</div>
);
}
export default MailboxDashboard;
+13
View File
@@ -0,0 +1,13 @@
export { MailboxDashboard } from "./MailboxDashboard";
export { MailboxApiError } from "../api/mailbox";
export type {
MailboxDashboardProps,
} from "./MailboxDashboard";
export type {
MessageSummary,
MessageList,
MessageDetail,
MailboxListParams,
ApiError,
MessageStatusUpdate,
} from "../api/mailbox";
+266
View File
@@ -0,0 +1,266 @@
@layer components {
.sidebar-nav {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--spacing-4);
background-color: var(--color-surface-card);
font-family: var(--font-family-sans);
}
.sidebar-nav__header {
margin-bottom: var(--spacing-6);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-neutral-gray-200);
}
.sidebar-nav__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.sidebar-nav__item {
margin: 0;
padding: 0;
}
.sidebar-nav__link {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-2) var(--spacing-3);
border-radius: var(--border-radius-default);
color: var(--color-neutral-gray-700);
text-decoration: none;
font-size: var(--font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-base);
transition: background-color var(--transition-fast), color var(--transition-fast);
border: none;
background: none;
cursor: pointer;
width: 100%;
text-align: left;
}
.sidebar-nav__link:hover {
background-color: var(--color-neutral-gray-50);
color: var(--color-neutral-gray-900);
}
.sidebar-nav__link:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
border-radius: var(--border-radius-default);
}
.sidebar-nav__link--active {
background-color: var(--color-brand-primary-light);
color: var(--color-brand-primary);
font-weight: var(--font-weight-medium);
}
.sidebar-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing-5);
height: var(--spacing-5);
flex-shrink: 0;
}
.sidebar-nav__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-nav__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--spacing-5);
height: var(--spacing-5);
padding: 0 var(--spacing-1);
border-radius: var(--border-radius-full);
background-color: var(--color-brand-primary);
color: var(--color-brand-primary-on);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-xs);
}
.sidebar-nav__footer {
margin-top: auto;
padding-top: var(--spacing-4);
border-top: 1px solid var(--color-neutral-gray-200);
}
.mobile-nav {
position: relative;
}
.mobile-nav__toggle {
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing-10);
height: var(--spacing-10);
border-radius: var(--border-radius-default);
background: none;
border: 1px solid var(--color-neutral-gray-200);
cursor: pointer;
padding: var(--spacing-2);
transition: background-color var(--transition-fast);
}
.mobile-nav__toggle:hover {
background-color: var(--color-neutral-gray-50);
}
.mobile-nav__toggle:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.mobile-nav__hamburger {
display: block;
width: var(--spacing-5);
height: var(--spacing-0_5);
background-color: var(--color-neutral-gray-700);
border-radius: var(--border-radius-sm);
position: relative;
}
.mobile-nav__hamburger::before,
.mobile-nav__hamburger::after {
content: "";
position: absolute;
left: 0;
width: 100%;
height: var(--spacing-0_5);
background-color: var(--color-neutral-gray-700);
border-radius: var(--border-radius-sm);
}
.mobile-nav__hamburger::before {
top: calc(var(--spacing-1) * -1 - var(--spacing-0_5));
}
.mobile-nav__hamburger::after {
top: calc(var(--spacing-1) + var(--spacing-0_5));
}
.mobile-nav__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-surface-overlay);
z-index: var(--z-index-overlay);
transition: opacity var(--transition-normal);
}
.mobile-nav__drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
background-color: var(--color-surface-card);
box-shadow: var(--shadow-xl);
transform: translateX(-100%);
transition: transform var(--transition-normal);
z-index: var(--z-index-modal);
overflow-y: auto;
padding: var(--spacing-4);
}
.mobile-nav__drawer--open {
transform: translateX(0);
}
.mobile-nav__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.mobile-nav__item {
margin: 0;
padding: 0;
}
.mobile-nav__link {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--border-radius-default);
color: var(--color-neutral-gray-700);
text-decoration: none;
font-size: var(--font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-base);
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.mobile-nav__link:hover {
background-color: var(--color-neutral-gray-50);
color: var(--color-neutral-gray-900);
}
.mobile-nav__link:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
border-radius: var(--border-radius-default);
}
.mobile-nav__link--active {
background-color: var(--color-brand-primary-light);
color: var(--color-brand-primary);
font-weight: var(--font-weight-medium);
}
.mobile-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing-5);
height: var(--spacing-5);
flex-shrink: 0;
}
.mobile-nav__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-nav__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--spacing-5);
height: var(--spacing-5);
padding: 0 var(--spacing-1);
border-radius: var(--border-radius-full);
background-color: var(--color-brand-primary);
color: var(--color-brand-primary-on);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-xs);
}
}
+87
View File
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SidebarNav, MobileNav } from "./SidebarNav";
import type { NavItem } from "./SidebarNav";
import "./SidebarNav.css";
import "../design-system/tokens.css";
const meta: Meta<typeof SidebarNav> = {
title: "Navigation/SidebarNav",
component: SidebarNav,
parameters: {
a11y: {
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "aria-allowed-attr", enabled: true },
{ id: "aria-valid-attr-value", enabled: true },
{ id: "button-name", enabled: true },
{ id: "link-name", enabled: true },
],
},
},
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof SidebarNav>;
const inboxItems: NavItem[] = [
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: 5 },
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
{ id: "settings", label: "Settings", href: "/settings" },
{ id: "help", label: "Help", href: "/help" },
];
const adminItems: NavItem[] = [
{ id: "dashboard", label: "Dashboard", href: "/admin" },
{ id: "logs", label: "Logs", href: "/admin/logs" },
{ id: "config", label: "Config", href: "/admin/config" },
{ id: "users", label: "Users", href: "/admin/users" },
{ id: "settings", label: "Settings", href: "/settings" },
];
export const Default: Story = {
args: {
items: inboxItems,
activeId: "inbox",
onNavigate: () => {},
},
};
export const ActiveArchive: Story = {
args: {
items: inboxItems,
activeId: "archive",
onNavigate: () => {},
},
};
export const AdminNavigation: Story = {
args: {
items: adminItems,
activeId: "dashboard",
onNavigate: () => {},
"aria-label": "Admin navigation",
},
};
export const WithHeaderAndFooter: Story = {
args: {
items: inboxItems,
activeId: "inbox",
onNavigate: () => {},
header: <div style={{ padding: "8px", fontWeight: 600, fontSize: "14px" }}>InboxOS</div>,
footer: <div style={{ padding: "8px", fontSize: "12px", color: "#6b7280" }}>v1.5</div>,
},
};
export const MobileDrawer: StoryObj<typeof MobileNav> = {
render: (args) => <MobileNav {...args} />,
args: {
items: inboxItems,
activeId: "inbox",
onNavigate: () => {},
},
};
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SidebarNav, MobileNav } from "./SidebarNav";
import type { NavItem } from "./SidebarNav";
const mockItems: NavItem[] = [
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: 3 },
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
{ id: "settings", label: "Settings", href: "/settings" },
];
describe("SidebarNav", () => {
it("renders navigation items", () => {
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
expect(screen.getByText("Inbox")).toBeInTheDocument();
expect(screen.getByText("Archive")).toBeInTheDocument();
expect(screen.getByText("Processed")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
it("marks active item with aria-current", () => {
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
const activeLink = screen.getByText("Inbox").closest("a");
expect(activeLink).toHaveAttribute("aria-current", "page");
});
it("does not set aria-current on inactive items", () => {
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
const archiveLink = screen.getByText("Archive").closest("a");
expect(archiveLink).not.toHaveAttribute("aria-current");
});
it("renders badge for items with unread count", () => {
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
expect(screen.getByLabelText("3 unread")).toBeInTheDocument();
});
it("calls onNavigate when item is clicked", () => {
const onNavigate = vi.fn();
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={onNavigate} />);
fireEvent.click(screen.getByText("Archive"));
expect(onNavigate).toHaveBeenCalledWith("archive");
});
it("renders with navigation role and aria-label", () => {
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
expect(screen.getByRole("navigation")).toHaveAttribute("aria-label", "Main navigation");
});
it("renders header and footer when provided", () => {
render(
<SidebarNav
items={mockItems}
activeId="inbox"
onNavigate={vi.fn()}
header={<div data-testid="header">Header</div>}
footer={<div data-testid="footer">Footer</div>}
/>
);
expect(screen.getByTestId("header")).toBeInTheDocument();
expect(screen.getByTestId("footer")).toBeInTheDocument();
});
it("renders custom aria-label", () => {
render(
<SidebarNav
items={mockItems}
activeId="inbox"
onNavigate={vi.fn()}
aria-label="Admin navigation"
/>
);
expect(screen.getByRole("navigation")).toHaveAttribute("aria-label", "Admin navigation");
});
});
describe("MobileNav", () => {
it("renders hamburger toggle button", () => {
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
expect(screen.getByRole("button", { name: /open navigation/i })).toBeInTheDocument();
});
it("opens drawer on toggle click", () => {
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
const toggle = screen.getByRole("button", { name: /open navigation/i });
fireEvent.click(toggle);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("closes drawer on Escape key", async () => {
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
const toggle = screen.getByRole("button", { name: /open navigation/i });
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(toggle).toHaveAttribute("aria-expanded", "false");
});
});
it("closes drawer on overlay click", async () => {
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
const toggle = screen.getByRole("button", { name: /open navigation/i });
fireEvent.click(toggle);
const overlay = document.querySelector(".mobile-nav__overlay");
expect(overlay).toBeInTheDocument();
fireEvent.click(overlay!);
await waitFor(() => {
expect(screen.getByRole("button", { name: /open navigation/i })).toHaveAttribute("aria-expanded", "false");
});
});
it("calls onNavigate and closes drawer when item is clicked", async () => {
const onNavigate = vi.fn();
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={onNavigate} />);
const toggle = screen.getByRole("button", { name: /open navigation/i });
fireEvent.click(toggle);
const archiveLink = screen.getByText("Archive");
fireEvent.click(archiveLink);
expect(onNavigate).toHaveBeenCalledWith("archive");
await waitFor(() => {
expect(screen.getByRole("button", { name: /open navigation/i })).toHaveAttribute("aria-expanded", "false");
});
});
it("marks active item in mobile nav", () => {
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
const toggle = screen.getByRole("button", { name: /open navigation/i });
fireEvent.click(toggle);
const inboxLink = screen.getByText("Inbox").closest("a");
expect(inboxLink).toHaveAttribute("aria-current", "page");
});
});
+198
View File
@@ -0,0 +1,198 @@
import { useState, useCallback, useRef, useEffect } from "react";
export interface NavItem {
id: string;
label: string;
href?: string;
icon?: React.ReactNode;
badge?: number;
onClick?: () => void;
}
export interface SidebarNavProps {
items: NavItem[];
activeId: string;
onNavigate: (id: string) => void;
header?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
"aria-label"?: string;
}
export function SidebarNav({
items,
activeId,
onNavigate,
header,
footer,
className,
"aria-label": ariaLabel = "Main navigation",
}: SidebarNavProps) {
return (
<nav
className={["sidebar-nav", className].filter(Boolean).join(" ")}
role="navigation"
aria-label={ariaLabel}
>
{header && <div className="sidebar-nav__header">{header}</div>}
<ul className="sidebar-nav__list" role="list">
{items.map((item) => {
const isActive = item.id === activeId;
const Tag = item.href ? "a" : "button";
return (
<li key={item.id} className="sidebar-nav__item" role="listitem">
<Tag
{...(item.href ? { href: item.href } : { type: "button" })}
className={[
"sidebar-nav__link",
isActive && "sidebar-nav__link--active",
]
.filter(Boolean)
.join(" ")}
aria-current={isActive ? "page" : undefined}
onClick={
item.onClick ??
((e: React.MouseEvent) => {
e.preventDefault();
onNavigate(item.id);
})
}
>
{item.icon && (
<span className="sidebar-nav__icon" aria-hidden="true">
{item.icon}
</span>
)}
<span className="sidebar-nav__label">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span
className="sidebar-nav__badge"
aria-label={`${item.badge} unread`}
>
{item.badge}
</span>
)}
</Tag>
</li>
);
})}
</ul>
{footer && <div className="sidebar-nav__footer">{footer}</div>}
</nav>
);
}
export interface MobileNavProps {
items: NavItem[];
activeId: string;
onNavigate: (id: string) => void;
className?: string;
"aria-label"?: string;
}
export function MobileNav({
items,
activeId,
onNavigate,
className,
"aria-label": ariaLabel = "Mobile navigation",
}: MobileNavProps) {
const [open, setOpen] = useState(false);
const toggleRef = useRef<HTMLButtonElement>(null);
const navRef = useRef<HTMLDivElement>(null);
const toggleMenu = useCallback(() => {
setOpen((prev) => !prev);
}, []);
const closeMenu = useCallback(() => {
setOpen(false);
toggleRef.current?.focus();
}, []);
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeMenu();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, closeMenu]);
return (
<div className={["mobile-nav", className].filter(Boolean).join(" ")}>
<button
ref={toggleRef}
className="mobile-nav__toggle"
onClick={toggleMenu}
aria-expanded={open}
aria-controls="mobile-nav-menu"
aria-label={open ? "Close navigation menu" : "Open navigation menu"}
>
<span className="mobile-nav__hamburger" aria-hidden="true" />
</button>
{open && (
<div
className="mobile-nav__overlay"
onClick={closeMenu}
aria-hidden="true"
/>
)}
<div
ref={navRef}
id="mobile-nav-menu"
className={[
"mobile-nav__drawer",
open && "mobile-nav__drawer--open",
]
.filter(Boolean)
.join(" ")}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
<ul className="mobile-nav__list" role="list">
{items.map((item) => {
const isActive = item.id === activeId;
return (
<li key={item.id} className="mobile-nav__item" role="listitem">
<a
href={item.href ?? "#"}
className={[
"mobile-nav__link",
isActive && "mobile-nav__link--active",
]
.filter(Boolean)
.join(" ")}
aria-current={isActive ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
onNavigate(item.id);
closeMenu();
}}
>
{item.icon && (
<span className="mobile-nav__icon" aria-hidden="true">
{item.icon}
</span>
)}
<span className="mobile-nav__label">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span
className="mobile-nav__badge"
aria-label={`${item.badge} unread`}
>
{item.badge}
</span>
)}
</a>
</li>
);
})}
</ul>
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { SidebarNav, MobileNav } from "./SidebarNav";
export type { NavItem, SidebarNavProps, MobileNavProps } from "./SidebarNav";
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useErrorReporting,
usePerformanceMetrics,
useNavigationReporting,
reportToObservability,
reportMetric,
} from "./index";
describe("useErrorReporting", () => {
it("provides reportError function", () => {
const { result } = renderHook(() => useErrorReporting("TestComponent"));
expect(result.current.reportError).toBeDefined();
expect(result.current.reportWarning).toBeDefined();
});
it("reportError dispatches custom event", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
const { result } = renderHook(() => useErrorReporting("TestComponent"));
act(() => {
result.current.reportError(new Error("test error"), { context: "unit-test" });
});
expect(dispatchSpy).toHaveBeenCalled();
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
expect(event.type).toBe("inboxos:report");
expect(event.detail.component).toBe("TestComponent");
expect(event.detail.action).toBe("error");
expect(event.detail.metadata.errorMessage).toBe("test error");
dispatchSpy.mockRestore();
});
it("reportWarning dispatches custom event", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
const { result } = renderHook(() => useErrorReporting("TestComponent"));
act(() => {
result.current.reportWarning("test warning");
});
expect(dispatchSpy).toHaveBeenCalled();
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
expect(event.detail.action).toBe("warning");
dispatchSpy.mockRestore();
});
});
describe("usePerformanceMetrics", () => {
it("provides reportRenderTime function", () => {
const { result } = renderHook(() => usePerformanceMetrics("TestComponent"));
expect(result.current.reportRenderTime).toBeDefined();
});
it("dispatches mount metric on mount", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
renderHook(() => usePerformanceMetrics("TestComponent"));
expect(dispatchSpy).toHaveBeenCalled();
const mountEvents = dispatchSpy.mock.calls.filter(
(call) => (call[0] as CustomEvent).type === "inboxos:metric"
);
const mountEvent = mountEvents.find(
(call) => (call[0] as CustomEvent).detail.name === "component.mount"
);
expect(mountEvent).toBeDefined();
dispatchSpy.mockRestore();
});
it("reportRenderTime dispatches metric event", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
const { result } = renderHook(() => usePerformanceMetrics("TestComponent"));
dispatchSpy.mockClear();
act(() => {
result.current.reportRenderTime("initial");
});
const renderEvents = dispatchSpy.mock.calls.filter(
(call) => (call[0] as CustomEvent).type === "inboxos:metric"
&& (call[0] as CustomEvent).detail.name === "component.render"
);
expect(renderEvents.length).toBeGreaterThan(0);
dispatchSpy.mockRestore();
});
});
describe("useNavigationReporting", () => {
it("provides reportNavigation function", () => {
const { result } = renderHook(() => useNavigationReporting("Sidebar"));
expect(result.current.reportNavigation).toBeDefined();
});
it("dispatches navigation event", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
const { result } = renderHook(() => useNavigationReporting("Sidebar"));
act(() => {
result.current.reportNavigation("/inbox", "/archive");
});
expect(dispatchSpy).toHaveBeenCalled();
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
expect(event.detail.action).toBe("navigate");
expect(event.detail.metadata.from).toBe("/inbox");
expect(event.detail.metadata.to).toBe("/archive");
dispatchSpy.mockRestore();
});
});
describe("reportToObservability (standalone)", () => {
it("includes source and timestamp", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
reportToObservability({ component: "Test", action: "test-action" });
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
expect(event.detail.source).toBe("inboxos-ui");
expect(event.detail.timestamp).toBeDefined();
dispatchSpy.mockRestore();
});
});
describe("reportMetric (standalone)", () => {
it("includes source and timestamp", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
reportMetric({ name: "test-metric", value: 42, tags: { component: "Test" } });
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
expect(event.detail.source).toBe("inboxos-ui-metric");
expect(event.detail.value).toBe(42);
dispatchSpy.mockRestore();
});
});
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useRef, useCallback } from "react";
interface ReportOptions {
component: string;
action: string;
metadata?: Record<string, string | number | boolean>;
}
function reportToObservability(options: ReportOptions): void {
if (typeof window === "undefined") return;
const payload = {
timestamp: new Date().toISOString(),
source: "inboxos-ui",
...options,
};
if (typeof window.dispatchEvent === "function") {
window.dispatchEvent(
new CustomEvent("inboxos:report", { detail: payload })
);
}
if (typeof console !== "undefined" && typeof console.debug === "function") {
console.debug("[inboxos-ui]", payload);
}
}
interface MetricOptions {
name: string;
value: number;
tags?: Record<string, string>;
}
function reportMetric(options: MetricOptions): void {
if (typeof window === "undefined") return;
const payload = {
timestamp: new Date().toISOString(),
source: "inboxos-ui-metric",
...options,
};
if (typeof window.dispatchEvent === "function") {
window.dispatchEvent(
new CustomEvent("inboxos:metric", { detail: payload })
);
}
if (typeof console !== "undefined" && typeof console.debug === "function") {
console.debug("[inboxos-metric]", payload);
}
}
export function useErrorReporting(componentName: string) {
const reportError = useCallback(
(error: Error, metadata?: Record<string, string>) => {
reportToObservability({
component: componentName,
action: "error",
metadata: {
errorMessage: error.message,
errorStack: error.stack ?? "",
...metadata,
},
});
},
[componentName]
);
const reportWarning = useCallback(
(message: string, metadata?: Record<string, string>) => {
reportToObservability({
component: componentName,
action: "warning",
metadata: { message, ...metadata },
});
},
[componentName]
);
return { reportError, reportWarning };
}
export function usePerformanceMetrics(componentName: string) {
const mountTimeRef = useRef<number | null>(null);
useEffect(() => {
mountTimeRef.current = performance.now();
reportMetric({
name: "component.mount",
value: performance.now(),
tags: { component: componentName },
});
return () => {
if (mountTimeRef.current !== null) {
const duration = performance.now() - mountTimeRef.current;
reportMetric({
name: "component.unmount",
value: duration,
tags: { component: componentName },
});
}
};
}, [componentName]);
const reportRenderTime = useCallback(
(label: string) => {
const now = performance.now();
reportMetric({
name: "component.render",
value: now,
tags: { component: componentName, label },
});
},
[componentName]
);
return { reportRenderTime };
}
export function useNavigationReporting(componentName: string) {
const reportNavigation = useCallback(
(from: string, to: string) => {
reportToObservability({
component: componentName,
action: "navigate",
metadata: { from, to },
});
},
[componentName]
);
return { reportNavigation };
}
export { reportToObservability, reportMetric };
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
conditions: ["development"],
},
define: {
"process.env.NODE_ENV": JSON.stringify("development"),
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test-setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.{ts,tsx}", "src/**/*.stories.{ts,tsx}", "src/**/*.d.ts"],
},
},
});