[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:
@@ -0,0 +1,14 @@
|
||||
export { MailboxApiError } from "./mailbox";
|
||||
export type {
|
||||
MessageSummary,
|
||||
MessageList,
|
||||
MessageDetail,
|
||||
MailboxListParams,
|
||||
ApiError,
|
||||
MessageStatusUpdate,
|
||||
} from "./mailbox";
|
||||
export {
|
||||
listMailboxMessages,
|
||||
getMailboxMessage,
|
||||
patchMessageStatus,
|
||||
} from "./mailbox";
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StatusMessage, Loading, EmptyState, StatusBadge } from "./StatusMessage";
|
||||
export type { StatusMessageProps, LoadingProps, EmptyStateProps, StatusBadgeProps } from "./StatusMessage";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AppShell } from "./AppShell";
|
||||
export type { AppShellProps } from "./AppShell";
|
||||
export { ResponsiveGrid, GridItem } from "./ResponsiveGrid";
|
||||
export type { ResponsiveGridProps, GridItemProps } from "./ResponsiveGrid";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: () => {},
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SidebarNav, MobileNav } from "./SidebarNav";
|
||||
export type { NavItem, SidebarNavProps, MobileNavProps } from "./SidebarNav";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user