Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { ReactNode } from "react";

type EmptyStateProps = {
eyebrow: string;
title: string;
description: string;
actionLabel: string;
onAction?: () => void;
/** Optional secondary CTA/help content shown with the primary action. */
children?: ReactNode;
/** Optional first-time guidance steps to display below the description. */
guidanceSteps?: string[];
};

export function EmptyState({ eyebrow, title, description, actionLabel, onAction, guidanceSteps }: EmptyStateProps) {
export function EmptyState({ eyebrow, title, description, actionLabel, onAction, children, guidanceSteps }: EmptyStateProps) {
return (
<section className="empty-state" aria-labelledby="empty-state-title">
<p className="empty-state__eyebrow">{eyebrow}</p>
Expand All @@ -28,6 +32,7 @@ export function EmptyState({ eyebrow, title, description, actionLabel, onAction,
<button className="button button--primary" type="button" onClick={onAction}>
{actionLabel}
</button>
{children}
</section>
);
}
1 change: 1 addition & 0 deletions app/components/StreamPrimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const StreamPrimer = ({ onClose }: StreamPrimerProps) => {

return (
<div
id="stream-primer-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="primer-title"
Expand Down
41 changes: 35 additions & 6 deletions app/streams/StreamsPageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"use client";

import { useCallback, useRef, useState } from "react";
import { EmptyState } from "../components/EmptyState";
import { PageError } from "../components/PageError";
import { StreamPrimer } from "../components/StreamPrimer";
import { StreamRow, type StreamRowData } from "../components/StreamRow";

export type StreamsViewState = "empty" | "loading" | "populated" | "error";
Expand All @@ -11,6 +15,7 @@ const streamListCopy = {
actionLabel: "Create Your First Stream",
description: "No streams yet. Create one to start paying collaborators and vendors on a steady schedule.",
eyebrow: "Streams",
primerLabel: "What is a payment stream?",
title: "Your streams list is empty",
},
heading: "Streams",
Expand Down Expand Up @@ -103,6 +108,13 @@ export function StreamsPageContent({
onRetry,
}: StreamsPageContentProps) {
const isEmpty = state === "empty" || streams.length === 0;
const [isPrimerOpen, setIsPrimerOpen] = useState(false);
const primerLinkRef = useRef<HTMLAnchorElement>(null);

const closePrimer = useCallback(() => {
setIsPrimerOpen(false);
primerLinkRef.current?.focus();
}, []);

return (
<main className="page-shell">
Expand Down Expand Up @@ -147,12 +159,29 @@ export function StreamsPageContent({
onRetry={onRetry}
/>
) : isEmpty ? (
<EmptyState
actionLabel={streamListCopy.empty.actionLabel}
description={streamListCopy.empty.description}
eyebrow={streamListCopy.empty.eyebrow}
title={streamListCopy.empty.title}
/>
<>
<EmptyState
actionLabel={streamListCopy.empty.actionLabel}
description={streamListCopy.empty.description}
eyebrow={streamListCopy.empty.eyebrow}
title={streamListCopy.empty.title}
>
<a
aria-controls={isPrimerOpen ? "stream-primer-dialog" : undefined}
aria-haspopup="dialog"
className="button button--secondary"
href="#stream-primer-dialog"
onClick={(event) => {
event.preventDefault();
setIsPrimerOpen(true);
}}
ref={primerLinkRef}
>
{streamListCopy.empty.primerLabel}
</a>
</EmptyState>
{isPrimerOpen && <StreamPrimer onClose={closePrimer} />}
</>
) : (
<section aria-label="Streams list" className="stream-list">
{streams.map((stream) => (
Expand Down
20 changes: 19 additions & 1 deletion app/streams/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @jest-environment jsdom
*/

import { render } from "@testing-library/react";
import { fireEvent, render, waitFor } from "@testing-library/react";
const { screen } = require("@testing-library/react") as any;
import { StreamsPageContent } from "./StreamsPageContent";

Expand All @@ -12,6 +12,24 @@ describe("StreamsPageContent", () => {

expect(screen.getByRole("heading", { name: /your streams list is empty/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /create your first stream/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /what is a payment stream/i })).toBeInTheDocument();
});

it("opens the stream primer from the empty state and restores focus on close", async () => {
render(<StreamsPageContent state="empty" streams={[]} />);

const primerLink = screen.getByRole("link", { name: /what is a payment stream/i });
primerLink.focus();
fireEvent.click(primerLink);

expect(screen.getByRole("dialog")).toBeInTheDocument();
const closeButton = screen.getByRole("button", { name: /close onboarding/i });
expect(closeButton).toHaveFocus();

fireEvent.click(closeButton);

await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(primerLink).toHaveFocus();
});

it("renders the loading skeleton state", () => {
Expand Down