From 0c6459ea3cc9cb52b1f8bb78a0339384f19d5ee2 Mon Sep 17 00:00:00 2001 From: Ejirowebfi Date: Sat, 27 Jun 2026 15:48:25 +0000 Subject: [PATCH] feat: add progressive disclosure to data tables for mobile usability --- src/components/InvoiceTable.tsx | 134 ++++++++--- src/components/LPEarningsHistory.tsx | 218 ++++++++++++++---- src/components/LPPortfolio.tsx | 3 + src/components/ProgressiveDisclosureCards.tsx | 124 ++++++++++ .../__tests__/InvoiceTableResponsive.test.tsx | 93 ++++++++ .../ProgressiveDisclosureCards.test.tsx | 87 +++++++ src/hooks/__tests__/useMediaQuery.test.ts | 69 ++++++ src/hooks/useMediaQuery.ts | 45 ++++ 8 files changed, 692 insertions(+), 81 deletions(-) create mode 100644 src/components/ProgressiveDisclosureCards.tsx create mode 100644 src/components/__tests__/InvoiceTableResponsive.test.tsx create mode 100644 src/components/__tests__/ProgressiveDisclosureCards.test.tsx create mode 100644 src/hooks/__tests__/useMediaQuery.test.ts create mode 100644 src/hooks/useMediaQuery.ts diff --git a/src/components/InvoiceTable.tsx b/src/components/InvoiceTable.tsx index ba1b2f7..cb05c18 100644 --- a/src/components/InvoiceTable.tsx +++ b/src/components/InvoiceTable.tsx @@ -1,14 +1,18 @@ -"use client"; +'use client'; -import React, { useState, useEffect, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import ColumnCustomiser, { ColumnConfig } from "./ColumnCustomiser"; +import React, { useState, useEffect, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import ColumnCustomiser, { ColumnConfig } from './ColumnCustomiser'; +import useMediaQuery, { MOBILE_QUERY } from '@/hooks/useMediaQuery'; +import ProgressiveDisclosureCards from './ProgressiveDisclosureCards'; export interface ColumnDefinition extends ColumnConfig { renderCell: (item: T) => React.ReactNode; headerClassName?: string; cellClassName?: string; sortable?: boolean; + /** Show this column in the collapsed mobile (progressive disclosure) summary. */ + isKeyColumn?: boolean; } interface InvoiceTableProps { @@ -20,7 +24,7 @@ interface InvoiceTableProps { emptyStateNode?: React.ReactNode; onSort?: (key: keyof T | string) => void; sortKey?: string; - sortOrder?: "asc" | "desc"; + sortOrder?: 'asc' | 'desc'; keyExtractor: (item: T) => string; // Selection selectedKeys?: Set; @@ -34,7 +38,7 @@ export default function InvoiceTable({ data, columns, isLoading, - emptyMessage = "No data found.", + emptyMessage = 'No data found.', emptyStateNode, onSort, sortKey, @@ -47,6 +51,7 @@ export default function InvoiceTable({ const router = useRouter(); const storageKey = `iln_table_config_${tableId}`; const selectable = selectedKeys !== undefined && onSelectionChange !== undefined; + const isMobile = useMediaQuery(MOBILE_QUERY); // State for order and visibility const [columnOrder, setColumnOrder] = useState([]); @@ -64,11 +69,11 @@ export default function InvoiceTable({ const config = JSON.parse(saved); const validOrder = config.order.filter((id: string) => columns.some((c) => c.id === id)); const missingFromOrder = defaultOrder.filter((id) => !validOrder.includes(id)); - + setColumnOrder([...validOrder, ...missingFromOrder]); setVisibleColumns(config.visibility || defaultVisible); } catch (e) { - console.error("Failed to load table config", e); + console.error('Failed to load table config', e); setColumnOrder(defaultOrder); setVisibleColumns(defaultVisible); } @@ -105,15 +110,15 @@ export default function InvoiceTable({ }; const handleKeyDown = (e: React.KeyboardEvent, item: T, index: number) => { - if (e.key === "Enter") { + if (e.key === 'Enter') { router.push(`/i/${keyExtractor(item)}`); - } else if (e.key === "ArrowDown") { + } else if (e.key === 'ArrowDown') { e.preventDefault(); - const nextRow = (e.currentTarget.nextSibling) as HTMLElement; + const nextRow = e.currentTarget.nextSibling as HTMLElement; nextRow?.focus(); - } else if (e.key === "ArrowUp") { + } else if (e.key === 'ArrowUp') { e.preventDefault(); - const prevRow = (e.currentTarget.previousSibling) as HTMLElement; + const prevRow = e.currentTarget.previousSibling as HTMLElement; prevRow?.focus(); } }; @@ -127,7 +132,8 @@ export default function InvoiceTable({ // Selection helpers const displayData = sortedData || data; const allKeys = useMemo(() => displayData.map(keyExtractor), [displayData, keyExtractor]); - const allSelected = selectable && allKeys.length > 0 && allKeys.every((k) => selectedKeys!.has(k)); + const allSelected = + selectable && allKeys.length > 0 && allKeys.every((k) => selectedKeys!.has(k)); const someSelected = selectable && allKeys.some((k) => selectedKeys!.has(k)); const handleSelectAll = () => { @@ -153,6 +159,53 @@ export default function InvoiceTable({ if (!isInitialised) return null; + // Mobile: progressive disclosure — show key columns, expand a row for the rest. + if (isMobile) { + const flaggedKeyIds = activeColumns.filter((c) => c.isKeyColumn).map((c) => c.id); + const keyColumnIds = + flaggedKeyIds.length > 0 + ? flaggedKeyIds + : activeColumns + .filter((c) => c.label !== '') + .slice(0, 3) + .map((c) => c.id); + + return ( +
+
+ +
+ + {isLoading ? ( +

Loading assets...

+ ) : displayData.length === 0 ? ( +
+ {emptyStateNode ?? emptyMessage} +
+ ) : ( + ({ + id: col.id, + label: col.label || 'Actions', + renderCell: col.renderCell, + }))} + keyExtractor={keyExtractor} + keyColumnIds={keyColumnIds} + className="px-2" + /> + )} +
+ ); + } + return (
@@ -189,15 +242,15 @@ export default function InvoiceTable({ key={col.id} onClick={() => col.sortable && onSort?.(col.id)} className={`px-6 py-4 text-[11px] font-bold uppercase text-on-surface-variant tracking-wider ${ - col.sortable ? "cursor-pointer select-none group" : "" - } ${col.headerClassName || ""}`} + col.sortable ? 'cursor-pointer select-none group' : '' + } ${col.headerClassName || ''}`} aria-sort={ col.sortable && sortKey === col.id - ? sortOrder === "asc" - ? "ascending" - : "descending" + ? sortOrder === 'asc' + ? 'ascending' + : 'descending' : col.sortable - ? "none" + ? 'none' : undefined } > @@ -205,23 +258,38 @@ export default function InvoiceTable({ {col.label} {idx === 0 && !selectable && (
- keyboard + + keyboard +
-
Shortcuts
+
+ Shortcuts +
- ↑↓ + + ↑↓ + Navigate rows
- + + ↵ + Open detail
)} {col.sortable && ( -
@@ -232,7 +300,10 @@ export default function InvoiceTable({ {isLoading ? ( - +
Loading assets... @@ -241,7 +312,10 @@ export default function InvoiceTable({ ) : displayData.length === 0 ? ( - + {emptyMessage} @@ -257,7 +331,7 @@ export default function InvoiceTable({ aria-selected={selectable ? isSelected : undefined} onKeyDown={(e) => handleKeyDown(e, item, index)} className={`hover:bg-surface-variant/10 transition-colors group focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset focus:bg-primary/5 ${ - isSelected ? "bg-primary/5" : "" + isSelected ? 'bg-primary/5' : '' }`} > {selectable && ( @@ -273,7 +347,7 @@ export default function InvoiceTable({ )} {activeColumns.map((col) => ( - + {col.renderCell(item)} ))} diff --git a/src/components/LPEarningsHistory.tsx b/src/components/LPEarningsHistory.tsx index 3ccbf7f..bea86e9 100644 --- a/src/components/LPEarningsHistory.tsx +++ b/src/components/LPEarningsHistory.tsx @@ -1,12 +1,14 @@ -"use client"; +'use client'; -import { useMemo, useState, useEffect } from "react"; -import { exportToCSV } from "@/utils/exportData"; -import { formatAddress, formatDate, formatTokenAmount, calculateYield } from "@/utils/format"; -import type { Invoice } from "@/utils/soroban"; -import FieldTooltip from "./FieldTooltip"; -import type { ApprovedToken } from "@/hooks/useApprovedTokens"; -import { fetchProtocolParameters } from "@/utils/governance"; +import { useMemo, useState, useEffect } from 'react'; +import { exportToCSV } from '@/utils/exportData'; +import { formatAddress, formatDate, formatTokenAmount, calculateYield } from '@/utils/format'; +import type { Invoice } from '@/utils/soroban'; +import FieldTooltip from './FieldTooltip'; +import type { ApprovedToken } from '@/hooks/useApprovedTokens'; +import { fetchProtocolParameters } from '@/utils/governance'; +import useMediaQuery, { MOBILE_QUERY } from '@/hooks/useMediaQuery'; +import ProgressiveDisclosureCards, { DisclosureColumn } from './ProgressiveDisclosureCards'; const PAGE_SIZE = 20; @@ -26,23 +28,23 @@ export default function LPEarningsHistory({ const paidInvoices = useMemo( () => invoices - .filter((invoice) => invoice.status === "Paid" && invoice.funder === walletAddress) + .filter((invoice) => invoice.status === 'Paid' && invoice.funder === walletAddress) .filter((invoice) => invoice.funded_at !== undefined && invoice.funded_at !== null), - [invoices, walletAddress], + [invoices, walletAddress] ); const sortedInvoices = useMemo( - () => - [...paidInvoices].sort((a, b) => - Number(b.funded_at ?? 0n) - Number(a.funded_at ?? 0n) - ), - [paidInvoices], + () => [...paidInvoices].sort((a, b) => Number(b.funded_at ?? 0n) - Number(a.funded_at ?? 0n)), + [paidInvoices] ); const [page, setPage] = useState(1); const pageCount = Math.max(1, Math.ceil(sortedInvoices.length / PAGE_SIZE)); const currentPage = Math.min(page, pageCount); - const visibleInvoices = sortedInvoices.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + const visibleInvoices = sortedInvoices.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE + ); const [protocolFeeBps, setProtocolFeeBps] = useState(null); @@ -59,32 +61,100 @@ export default function LPEarningsHistory({ }; }, []); - const getToken = (invoice: Invoice) => tokenMap.get(invoice.token ?? defaultToken?.contractId ?? "") ?? defaultToken; + const isMobile = useMediaQuery(MOBILE_QUERY); + + const getToken = (invoice: Invoice) => + tokenMap.get(invoice.token ?? defaultToken?.contractId ?? '') ?? defaultToken; + + // Column config reused by the mobile progressive-disclosure cards. + const mobileColumns: DisclosureColumn[] = [ + { + id: 'id', + label: 'Invoice ID', + renderCell: (inv) => #{inv.id.toString()}, + }, + { + id: 'amount', + label: 'Amount Funded', + renderCell: (inv) => + formatTokenAmount(inv.amount, getToken(inv) ?? { symbol: 'USDC', decimals: 6 }), + }, + { + id: 'earned', + label: 'Earned', + renderCell: (inv) => ( + + {formatTokenAmount( + calculateYield(inv.amount, inv.discount_rate), + getToken(inv) ?? { symbol: 'USDC', decimals: 6 } + )} + + ), + }, + { id: 'payer', label: 'Payer', renderCell: (inv) => formatAddress(inv.payer) }, + { + id: 'settlement', + label: 'Settlement Date', + renderCell: (inv) => (inv.funded_at ? formatDate(inv.funded_at) : 'N/A'), + }, + { + id: 'payout', + label: 'Payout Received', + renderCell: (inv) => + formatTokenAmount( + inv.amount + calculateYield(inv.amount, inv.discount_rate), + getToken(inv) ?? { symbol: 'USDC', decimals: 6 } + ), + }, + { + id: 'fee', + label: 'Fee Paid', + renderCell: (inv) => { + const yieldAmount = calculateYield(inv.amount, inv.discount_rate); + const feePaid = protocolFeeBps ? (yieldAmount * BigInt(protocolFeeBps)) / 10000n : 0n; + return formatTokenAmount(feePaid, getToken(inv) ?? { symbol: 'USDC', decimals: 6 }); + }, + }, + { id: 'token', label: 'Token', renderCell: (inv) => getToken(inv)?.symbol ?? 'USDC' }, + { + id: 'yield', + label: 'Yield %', + renderCell: (inv) => `${(inv.discount_rate / 100).toFixed(2)}%`, + }, + ]; const exportData = sortedInvoices.map((invoice) => { const token = getToken(invoice); const yieldAmount = calculateYield(invoice.amount, invoice.discount_rate); const payoutReceived = invoice.amount + yieldAmount; - const amountFunded = formatTokenAmount(invoice.amount, token ?? { symbol: "USDC", decimals: 6 }); - const payout = formatTokenAmount(payoutReceived, token ?? { symbol: "USDC", decimals: 6 }); - const earned = formatTokenAmount(yieldAmount, token ?? { symbol: "USDC", decimals: 6 }); - const feePaid = protocolFeeBps ? formatTokenAmount((yieldAmount * BigInt(protocolFeeBps)) / 10000n, token ?? { symbol: "USDC", decimals: 6 }) : "0"; + const amountFunded = formatTokenAmount( + invoice.amount, + token ?? { symbol: 'USDC', decimals: 6 } + ); + const payout = formatTokenAmount(payoutReceived, token ?? { symbol: 'USDC', decimals: 6 }); + const earned = formatTokenAmount(yieldAmount, token ?? { symbol: 'USDC', decimals: 6 }); + const feePaid = protocolFeeBps + ? formatTokenAmount( + (yieldAmount * BigInt(protocolFeeBps)) / 10000n, + token ?? { symbol: 'USDC', decimals: 6 } + ) + : '0'; return { - "Invoice ID": `#${invoice.id.toString()}`, + 'Invoice ID': `#${invoice.id.toString()}`, Payer: formatAddress(invoice.payer), - "Settlement Date": invoice.funded_at ? formatDate(invoice.funded_at) : "N/A", - "Amount Funded": amountFunded, - "Payout Received": payout, - Earned: earned, - "Fee Paid": feePaid, - Token: token?.symbol ?? "USDC", - "Yield %": `${(invoice.discount_rate / 100).toFixed(2)}%`, + 'Settlement Date': invoice.funded_at ? formatDate(invoice.funded_at) : 'N/A', + 'Amount Funded': amountFunded, + 'Payout Received': payout, + Earned: earned, + 'Fee Paid': feePaid, + Token: token?.symbol ?? 'USDC', + 'Yield %': `${(invoice.discount_rate / 100).toFixed(2)}%`, }; }); const handleExport = () => { - const dateStr = new Date().toISOString().split("T")[0]; + const dateStr = new Date().toISOString().split('T')[0]; exportToCSV(exportData, `ILN-LP-Earnings-${dateStr}.csv`); }; @@ -124,27 +194,55 @@ export default function LPEarningsHistory({
No settled earnings history is available yet.
+ ) : isMobile ? ( + inv.id.toString()} + keyColumnIds={['id', 'amount', 'earned']} + /> ) : (
- - - - - - + + + + + + - - + + @@ -152,19 +250,35 @@ export default function LPEarningsHistory({ const token = getToken(invoice); const yieldAmount = calculateYield(invoice.amount, invoice.discount_rate); const payoutReceived = invoice.amount + yieldAmount; - const feePaid = protocolFeeBps ? (yieldAmount * BigInt(protocolFeeBps)) / 10000n : 0n; + const feePaid = protocolFeeBps + ? (yieldAmount * BigInt(protocolFeeBps)) / 10000n + : 0n; return ( - - - - - - - - + + + + + + + + ); })} @@ -176,7 +290,9 @@ export default function LPEarningsHistory({ {sortedInvoices.length > PAGE_SIZE && (

- Showing {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, sortedInvoices.length)} of {sortedInvoices.length} records + Showing {(currentPage - 1) * PAGE_SIZE + 1}– + {Math.min(currentPage * PAGE_SIZE, sortedInvoices.length)} of {sortedInvoices.length}{' '} + records

+ + {detailColumns.length > 0 && ( + + )} + + ); + })} + + ); +} diff --git a/src/components/__tests__/InvoiceTableResponsive.test.tsx b/src/components/__tests__/InvoiceTableResponsive.test.tsx new file mode 100644 index 0000000..37c8f0c --- /dev/null +++ b/src/components/__tests__/InvoiceTableResponsive.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import InvoiceTable, { ColumnDefinition } from '../InvoiceTable'; + +interface Row { + id: string; + status: string; + amount: string; + payer: string; +} + +const columns: ColumnDefinition[] = [ + { id: 'id', label: 'ID', isMandatory: true, isKeyColumn: true, renderCell: (r) => `#${r.id}` }, + { + id: 'status', + label: 'Status', + isMandatory: true, + isKeyColumn: true, + renderCell: (r) => r.status, + }, + { id: 'amount', label: 'Amount', isKeyColumn: true, renderCell: (r) => r.amount }, + { id: 'payer', label: 'Payer', renderCell: (r) => r.payer }, +]; + +const data: Row[] = [{ id: '1', status: 'Paid', amount: '$100', payer: 'GABC' }]; + +const original = window.matchMedia; + +function setViewport(isMobile: boolean) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: isMobile, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +describe('InvoiceTable responsive progressive disclosure', () => { + beforeEach(() => localStorage.clear()); + afterEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: original, + }); + }); + + it('renders a full
Invoice IDPayerSettlement DateAmount FundedPayout ReceivedEarned + Invoice ID + + Payer + + Settlement Date + + Amount Funded + + Payout Received + + Earned +
Fee Paid - info - } /> + + info + + } + />
TokenYield % + Token + + Yield % +
#{invoice.id.toString()}{formatAddress(invoice.payer)}{invoice.funded_at ? formatDate(invoice.funded_at) : "N/A"}{formatTokenAmount(invoice.amount, token ?? { symbol: "USDC", decimals: 6 })}{formatTokenAmount(payoutReceived, token ?? { symbol: "USDC", decimals: 6 })}{formatTokenAmount(yieldAmount, token ?? { symbol: "USDC", decimals: 6 })}{formatTokenAmount(feePaid, token ?? { symbol: "USDC", decimals: 6 })}{token?.symbol ?? "USDC"}{(invoice.discount_rate / 100).toFixed(2)}% + {formatAddress(invoice.payer)} + + {invoice.funded_at ? formatDate(invoice.funded_at) : 'N/A'} + + {formatTokenAmount(invoice.amount, token ?? { symbol: 'USDC', decimals: 6 })} + + {formatTokenAmount(payoutReceived, token ?? { symbol: 'USDC', decimals: 6 })} + + {formatTokenAmount(yieldAmount, token ?? { symbol: 'USDC', decimals: 6 })} + + {formatTokenAmount(feePaid, token ?? { symbol: 'USDC', decimals: 6 })} + {token?.symbol ?? 'USDC'} + {(invoice.discount_rate / 100).toFixed(2)}% +
on desktop', () => { + setViewport(false); + const { container } = render( + r.id} + /> + ); + expect(container.querySelector('table')).toBeInTheDocument(); + expect(screen.getByText('GABC')).toBeInTheDocument(); + }); + + it('renders progressive disclosure cards (no table) on mobile', () => { + setViewport(true); + const { container } = render( + r.id} + /> + ); + // No desktop table + expect(container.querySelector('table')).not.toBeInTheDocument(); + // Key columns are visible, detail column is hidden until expanded + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('Paid')).toBeInTheDocument(); + + const toggle = screen.getAllByRole('button').find((b) => b.hasAttribute('aria-expanded'))!; + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('GABC')).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/ProgressiveDisclosureCards.test.tsx b/src/components/__tests__/ProgressiveDisclosureCards.test.tsx new file mode 100644 index 0000000..b185936 --- /dev/null +++ b/src/components/__tests__/ProgressiveDisclosureCards.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { axe } from 'jest-axe'; +import ProgressiveDisclosureCards, { DisclosureColumn } from '../ProgressiveDisclosureCards'; + +interface Row { + id: string; + status: string; + amount: string; + payer: string; + due: string; +} + +const data: Row[] = [ + { id: '1', status: 'Paid', amount: '$100', payer: 'GABC', due: '2026-01-01' }, + { id: '2', status: 'Funded', amount: '$200', payer: 'GXYZ', due: '2026-02-01' }, +]; + +const columns: DisclosureColumn[] = [ + { id: 'id', label: 'Invoice ID', renderCell: (r) => `#${r.id}` }, + { id: 'status', label: 'Status', renderCell: (r) => r.status }, + { id: 'amount', label: 'Amount', renderCell: (r) => r.amount }, + { id: 'payer', label: 'Payer', renderCell: (r) => r.payer }, + { id: 'due', label: 'Due Date', renderCell: (r) => r.due }, +]; + +const renderCards = () => + render( + r.id} + keyColumnIds={['id', 'status', 'amount']} + /> + ); + +describe('ProgressiveDisclosureCards', () => { + it('shows key columns collapsed and hides detail columns', () => { + renderCards(); + // Key columns visible + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('Paid')).toBeInTheDocument(); + expect(screen.getByText('$100')).toBeInTheDocument(); + // Detail panel is collapsed + const toggles = screen.getAllByRole('button'); + expect(toggles[0]).toHaveAttribute('aria-expanded', 'false'); + const panel = document.getElementById(toggles[0].getAttribute('aria-controls')!); + expect(panel).toBeTruthy(); + expect(panel).toHaveAttribute('hidden'); + }); + + it('expands a row to reveal all fields when activated (ARIA expanded)', () => { + renderCards(); + const toggle = screen.getAllByRole('button')[0]; + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + const panel = document.getElementById(toggle.getAttribute('aria-controls')!); + expect(panel).not.toHaveAttribute('hidden'); + // Detail fields now present + expect(screen.getByText('GABC')).toBeInTheDocument(); + expect(screen.getByText('2026-01-01')).toBeInTheDocument(); + }); + + it('toggles independently per row and can collapse again', () => { + renderCards(); + const [first, second] = screen.getAllByRole('button'); + fireEvent.click(first); + expect(first).toHaveAttribute('aria-expanded', 'true'); + expect(second).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(first); + expect(first).toHaveAttribute('aria-expanded', 'false'); + }); + + it('is keyboard accessible (toggle is a native button)', () => { + renderCards(); + const toggle = screen.getAllByRole('button')[0]; + expect(toggle.tagName).toBe('BUTTON'); + toggle.focus(); + expect(toggle).toHaveFocus(); + }); + + it('has no accessibility violations', async () => { + const { container } = renderCards(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/hooks/__tests__/useMediaQuery.test.ts b/src/hooks/__tests__/useMediaQuery.test.ts new file mode 100644 index 0000000..57d0bdf --- /dev/null +++ b/src/hooks/__tests__/useMediaQuery.test.ts @@ -0,0 +1,69 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import useMediaQuery from '../useMediaQuery'; + +type Listener = (e: { matches: boolean }) => void; + +function mockMatchMedia(initialMatches: boolean) { + let listener: Listener | null = null; + const mql = { + matches: initialMatches, + media: '', + addEventListener: vi.fn((_: string, cb: Listener) => { + listener = cb; + }), + removeEventListener: vi.fn(() => { + listener = null; + }), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + onchange: null, + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => { + mql.media = query; + return mql; + }), + }); + return { + emit: (matches: boolean) => { + mql.matches = matches; + act(() => listener?.({ matches })); + }, + }; +} + +describe('useMediaQuery', () => { + const original = window.matchMedia; + beforeEach(() => vi.clearAllMocks()); + afterEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: original, + }); + }); + + it('returns the initial match state', () => { + mockMatchMedia(true); + const { result } = renderHook(() => useMediaQuery('(max-width: 639px)')); + expect(result.current).toBe(true); + }); + + it('returns false when the query does not match', () => { + mockMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(max-width: 639px)')); + expect(result.current).toBe(false); + }); + + it('updates when the media query changes', () => { + const { emit } = mockMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(max-width: 639px)')); + expect(result.current).toBe(false); + emit(true); + expect(result.current).toBe(true); + }); +}); diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..8f2dc0c --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -0,0 +1,45 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Subscribe to a CSS media query and return whether it currently matches. + * + * SSR-safe: returns `false` during server render and the first client render, + * then syncs to the real value in an effect to avoid hydration mismatches. + * + * @example + * const isMobile = useMediaQuery("(max-width: 639px)"); + */ +export default function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const mediaQueryList = window.matchMedia(query); + const handleChange = (event: MediaQueryListEvent | MediaQueryList) => { + setMatches(event.matches); + }; + + // Sync immediately in case the query already matches on mount. + handleChange(mediaQueryList); + + // `addEventListener` is the modern API; fall back to `addListener` for + // older Safari/jsdom environments. + if (typeof mediaQueryList.addEventListener === 'function') { + mediaQueryList.addEventListener('change', handleChange); + return () => mediaQueryList.removeEventListener('change', handleChange); + } + + mediaQueryList.addListener(handleChange); + return () => mediaQueryList.removeListener(handleChange); + }, [query]); + + return matches; +} + +/** Breakpoint used across data tables for progressive disclosure (< 640px). */ +export const MOBILE_QUERY = '(max-width: 639px)';