Skip to content
Merged
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
21 changes: 17 additions & 4 deletions docs/BillingHistoryPreview.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ The Billing History page displays a paginated table of USDC transactions. Each r
4. Press **Escape** to dismiss the card and return focus to the trigger.
5. Tab again to move to the next row (card closes automatically on blur).

### Touch user (below 640px)

Below 640px the table is replaced by a list of transaction cards. Only one of the two layouts is in the DOM at a time, so screen readers never read both.

1. Each card is a list item with a heading (the description), a status badge, the signed amount (`+` for credits, a minus sign for debits), the date, and a **Details** button.
2. Tap **Details** to open a bottom sheet with the same on-chain details as the hover preview: transaction hash (with a **Copy** button), network, confirmations and timestamp.
3. Close the sheet with the close button, a tap on the backdrop, or **Escape**. Focus returns to that card's Details button.
4. Sorting uses a single **Sort by** select (date or amount, either direction). It writes the same `sort` and `dir` URL params as the desktop column headers, so a shared link sorts the same way on both layouts.
5. Filters stack in one column and behave exactly as on desktop. The net-balance summary stays at the top.

The breakpoint is `(max-width: 639px)`. It is read in JavaScript by the `useMediaQuery` hook (to choose which layout to render) and in CSS (to stack the filters). All touch targets are at least 44x44px.

---

## Filtering
Expand Down Expand Up @@ -187,11 +199,12 @@ All colours in `PreviewCard` and `BillingHistory` use CSS custom properties so b

## Responsive behaviour

- The table container uses `overflow-x: auto` so horizontal scrolling is available on small viewports.
- The preview card has `maxWidth: 90vw` — it never overflows the viewport on narrow screens.
- Filter controls use `flexWrap: wrap` so they stack on small screens without clipping.
- **Below 640px:** transactions render as cards (see "Touch user" above). The table is not rendered, so there is no horizontal scrolling at 320px.
- **640px and above:** the table renders as before. Its container keeps `overflow-x: auto` as a fallback for narrow desktop windows.
- The preview card has `maxWidth: 90vw`, so it never overflows the viewport on narrow screens.
- Filter controls wrap on wider screens and stack into one column below 640px.
- The page heading uses `clamp(1.35rem, 3vw, 1.75rem)` for fluid sizing.

- Styles live in `src/pages/BillingHistory.css` (`.billing-history .bh-*` classes), not inline.
---

## Running the tests
Expand Down
30 changes: 30 additions & 0 deletions src/components/BillingSortSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BillingSortSelect } from './BillingSortSelect';

const getSelect = () => screen.getByRole('combobox', { name: /sort transactions/i }) as HTMLSelectElement;

describe('BillingSortSelect', () => {
afterEach(cleanup);

it('shows a disabled "Default order" placeholder when nothing is sorted', () => {
render(<BillingSortSelect column={null} direction="asc" onChange={vi.fn()} />);
expect(getSelect().value).toBe('');
expect(screen.getByRole('option', { name: 'Default order' })).toBeTruthy();
});

it('offers the four date/amount options and no placeholder once sorted', () => {
render(<BillingSortSelect column="amount" direction="desc" onChange={vi.fn()} />);
expect(getSelect().value).toBe('amount:desc');
expect(screen.getAllByRole('option')).toHaveLength(4);
expect(screen.queryByRole('option', { name: 'Default order' })).toBeNull();
});

it('reports the chosen column and direction', () => {
const onChange = vi.fn();
render(<BillingSortSelect column={null} direction="asc" onChange={onChange} />);
fireEvent.change(getSelect(), { target: { value: 'date:asc' } });
expect(onChange).toHaveBeenCalledWith('date', 'asc');
});
});
53 changes: 53 additions & 0 deletions src/components/BillingSortSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { SortColumn, SortDirection } from '../pages/BillingHistory';

interface SortOption {
column: SortColumn;
direction: SortDirection;
label: string;
}

const SORT_OPTIONS: readonly SortOption[] = [
{ column: 'date', direction: 'desc', label: 'Date: newest first' },
{ column: 'date', direction: 'asc', label: 'Date: oldest first' },
{ column: 'amount', direction: 'desc', label: 'Amount: high to low' },
{ column: 'amount', direction: 'asc', label: 'Amount: low to high' },
];

const toValue = (column: SortColumn, direction: SortDirection) => `${column}:${direction}`;

export interface BillingSortSelectProps {
column: SortColumn | null;
direction: SortDirection;
onChange: (column: SortColumn, direction: SortDirection) => void;
}

/** One select that replaces the sortable table headers on small screens. */
export function BillingSortSelect({ column, direction, onChange }: BillingSortSelectProps) {
return (
<label className="bh-field">
<span className="bh-field__label">Sort by</span>
<select
className="bh-field__control"
aria-label="Sort transactions"
value={column ? toValue(column, direction) : ''}
onChange={(e) => {
const option = SORT_OPTIONS.find((o) => toValue(o.column, o.direction) === e.target.value);
if (option) onChange(option.column, option.direction);
}}
>
{column === null && (
<option value="" disabled>
Default order
</option>
)}
{SORT_OPTIONS.map((o) => (
<option key={toValue(o.column, o.direction)} value={toValue(o.column, o.direction)}>
{o.label}
</option>
))}
</select>
</label>
);
}

export default BillingSortSelect;
2 changes: 1 addition & 1 deletion src/components/PreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function formatUsdcAmount(value: number): string {
* Format an ISO 8601 timestamp into a human-readable string.
* Falls back to the raw string if parsing fails.
*/
function formatTimestamp(iso: string): string {
export function formatTimestamp(iso: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
Expand Down
66 changes: 66 additions & 0 deletions src/components/TransactionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TransactionCard } from './TransactionCard';
import type { BillingTransaction } from '../pages/BillingHistory';

const tx: BillingTransaction = {
id: 'tx-1',
description: 'USDC vault deposit',
type: 'Deposit',
direction: 'credit',
amount: 100,
status: 'success',
timestamp: '2026-07-25T14:32:00Z',
txHash: 'A'.repeat(64),
network: 'Stellar Mainnet',
confirmations: 120,
};

function renderCard(overrides: Partial<BillingTransaction> = {}, onDetails = vi.fn()) {
const item = { ...tx, ...overrides };
render(
<ul>
<TransactionCard tx={item} dateText="Jul 25, 02:32 PM" statusLabel="Confirmed" onDetails={onDetails} />
</ul>,
);
return { item, onDetails };
}

describe('TransactionCard', () => {
afterEach(cleanup);

it('is a list item with the description as its heading', () => {
renderCard();
expect(screen.getByRole('listitem')).toBeTruthy();
expect(screen.getByRole('heading', { name: 'USDC vault deposit' })).toBeTruthy();
});

it('shows a plus sign for credits and a minus sign for debits', () => {
renderCard();
expect(screen.getByText(/^\+.*USDC$/)).toBeTruthy();
cleanup();
renderCard({ direction: 'debit' });
expect(screen.getByText(/^\u2212.*USDC$/)).toBeTruthy();
});

it('shows the date and a text status label (not colour alone)', () => {
renderCard();
expect(screen.getByText('Jul 25, 02:32 PM')).toBeTruthy();
expect(screen.getByText('Confirmed')).toBeTruthy();
});

it('calls onDetails with the transaction and the tapped button', () => {
const { item, onDetails } = renderCard();
const button = screen.getByRole('button', { name: /^details/i });
fireEvent.click(button);
expect(onDetails).toHaveBeenCalledWith(item, button);
});

it('labels the Details button with the date so identical descriptions stay distinguishable', () => {
renderCard();
expect(
screen.getByRole('button', { name: 'Details for USDC vault deposit, Jul 25, 02:32 PM' }),
).toBeTruthy();
});
});
40 changes: 40 additions & 0 deletions src/components/TransactionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import StatusBadge from './StatusBadge';
import { formatUsdcAmount } from '../utils/format';
import type { BillingTransaction } from '../pages/BillingHistory';

export interface TransactionCardProps {
tx: BillingTransaction;
/** Already-formatted, timezone-aware date text. */
dateText: string;
statusLabel: string;
/** Receives the button so the caller can return focus to it later. */
onDetails: (tx: BillingTransaction, trigger: HTMLButtonElement) => void;
}

export function TransactionCard({ tx, dateText, statusLabel, onDetails }: TransactionCardProps) {
const sign = tx.direction === 'credit' ? '+' : '\u2212';
return (
<li className="bh-card" data-testid={`bh-card-${tx.id}`}>
<div className="bh-card__top">
<h2 className="bh-card__title">{tx.description}</h2>
<StatusBadge status={tx.status} label={statusLabel} />
</div>
<p className={`bh-card__amount tabular-nums bh-tone--${tx.direction}`}>
{sign}{formatUsdcAmount(tx.amount)} USDC
</p>
<div className="bh-card__footer">
<time dateTime={tx.timestamp}>{dateText}</time>
<button
type="button"
className="bh-card__details"
aria-label={`Details for ${tx.description}, ${dateText}`}
onClick={(e) => onDetails(tx, e.currentTarget)}
>
Details
</button>
</div>
</li>
);
}

export default TransactionCard;
53 changes: 53 additions & 0 deletions src/components/TransactionDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TransactionDetails } from './TransactionDetails';
import type { PreviewCardData } from './PreviewCard';

const HASH = 'A3F9B2C1D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0A1';
const data: PreviewCardData = {
id: 'tx-1',
title: 'USDC vault deposit',
txHash: HASH,
network: 'Stellar Mainnet',
confirmations: 120,
timestamp: '2026-07-25T14:32:00Z',
};

function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
return writeText;
}

describe('TransactionDetails', () => {
afterEach(() => {
cleanup();
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
});

it('shows the network, confirmations, full hash and a formatted time', () => {
stubClipboard();
render(<TransactionDetails data={data} />);
expect(screen.getByText('Stellar Mainnet')).toBeTruthy();
expect(screen.getByText('120')).toBeTruthy();
expect(screen.getByText(HASH)).toBeTruthy();
expect(screen.getByText(/2026/)).toBeTruthy();
});

it('copies the full hash and confirms it', async () => {
const writeText = stubClipboard();
render(<TransactionDetails data={data} />);
fireEvent.click(screen.getByRole('button', { name: 'Copy transaction hash' }));
expect(await screen.findByText('Copied!')).toBeTruthy();
expect(writeText).toHaveBeenCalledWith(HASH);
});

it('omits rows it has no data for', () => {
stubClipboard();
render(<TransactionDetails data={{ id: 'tx-2', title: 'Bare' }} />);
expect(screen.queryByText('Network')).toBeNull();
expect(screen.queryByText('Confirmations')).toBeNull();
expect(screen.queryByRole('button', { name: /copy/i })).toBeNull();
});
});
52 changes: 52 additions & 0 deletions src/components/TransactionDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useCopy } from '../hooks/useCopy';
import { formatTimestamp } from './PreviewCard';
import type { PreviewCardData } from './PreviewCard';

/** On-chain details for one transaction: the same PreviewCardData the desktop hover preview shows. */
export function TransactionDetails({ data }: { data: PreviewCardData }) {
const { copied, supported, handleCopy } = useCopy();
return (
<dl className="bh-details">
{data.network && (
<>
<dt>Network</dt>
<dd>{data.network}</dd>
</>
)}
{data.confirmations !== undefined && (
<>
<dt>Confirmations</dt>
<dd>{data.confirmations}</dd>
</>
)}
{data.txHash && (
<>
<dt>Transaction hash</dt>
<dd className="bh-details__hash">
<code>{data.txHash}</code>
{supported && (
<button
type="button"
className="bh-details__copy"
aria-label="Copy transaction hash"
onClick={() => handleCopy(data.txHash as string)}
>
{copied ? 'Copied!' : 'Copy'}
</button>
)}
</dd>
</>
)}
{data.timestamp && (
<>
<dt>Time</dt>
<dd>
<time dateTime={data.timestamp}>{formatTimestamp(data.timestamp)}</time>
</dd>
</>
)}
</dl>
);
}

export default TransactionDetails;
5 changes: 4 additions & 1 deletion src/data/a11y-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"components": [
{ "id": "ApiCard", "name": "ApiCard", "status": "audited" },
{ "id": "BillingSortSelect", "name": "BillingSortSelect", "status": "audited" },
{ "id": "Breadcrumb", "name": "Breadcrumb", "status": "needs-work" },
{ "id": "CodeExample", "name": "CodeExample", "status": "audited" },
{ "id": "Dashboard", "name": "Dashboard", "status": "n/a" },
Expand All @@ -14,6 +15,8 @@
{ "id": "SearchBar", "name": "SearchBar", "status": "audited" },
{ "id": "ServerError", "name": "ServerError", "status": "n/a" },
{ "id": "ServerErrorDemo", "name": "ServerErrorDemo", "status": "n/a" },
{ "id": "Skeleton", "name": "Skeleton", "status": "audited" }
{ "id": "Skeleton", "name": "Skeleton", "status": "audited" },
{ "id": "TransactionCard", "name": "TransactionCard", "status": "audited" },
{ "id": "TransactionDetails", "name": "TransactionDetails", "status": "audited" }
]
}
Loading
Loading