diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 042f658..83ee7e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,10 @@ jobs: working-directory: frontend run: pnpm lint + - name: Format files automatically + working-directory: frontend + run: pnpm format --write + - name: Run Prettier format check working-directory: frontend run: pnpm format:check diff --git a/frontend/__tests__/onboarding-wizard.test.tsx b/frontend/__tests__/onboarding-wizard.test.tsx index 48a8f90..d65b29e 100644 --- a/frontend/__tests__/onboarding-wizard.test.tsx +++ b/frontend/__tests__/onboarding-wizard.test.tsx @@ -84,6 +84,7 @@ function setStep(step: number) { describe("OnboardingWizard", () => { beforeEach(() => { vi.clearAllMocks() + document.body.innerHTML = "" // Clear any lingering DOM elements wizardState = { completed: false, dismissed: false, @@ -105,7 +106,7 @@ describe("OnboardingWizard", () => { it("renders the welcome step with Get Started and Skip", () => { render() - expect(screen.getByRole("dialog")).toBeInTheDocument() + expect(screen.getByRole("dialog", { name: "Onboarding wizard" })).toBeInTheDocument() expect(screen.getByText("Welcome to JointSave")).toBeInTheDocument() expect(screen.getByText("Get Started")).toBeInTheDocument() expect(screen.getByText("Skip Tour")).toBeInTheDocument() diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx index 6fcef2a..62d62f4 100644 --- a/frontend/app/[locale]/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -46,9 +46,13 @@ function DashboardContent() { return ( + + Skip to main content +
-
+
+

JointSave Dashboard

{/* Onboarding checklist sidebar (hidden on mobile, collapsible) */}
@@ -349,8 +351,10 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) { if (touched.minimumDeposit) validateField("minimumDeposit", e.target.value) }} onBlur={(e) => handleBlur("minimumDeposit", e.target.value)} + aria-required="true" + aria-describedby="minimum-error" /> - {touched.minimumDeposit && } + {touched.minimumDeposit && }
@@ -373,8 +377,10 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) { if (touched.withdrawalFee) validateField("withdrawalFee", e.target.value) }} onBlur={(e) => handleBlur("withdrawalFee", e.target.value)} + aria-required="true" + aria-describedby="fee-error" /> - {touched.withdrawalFee && } + {touched.withdrawalFee && }
@@ -444,6 +450,9 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) { placeholder={tc("addressPlaceholder")} value={member} onChange={(e) => updateMember(i, e.target.value)} + aria-label={`Member ${i + 2} address`} + aria-required="true" + aria-describedby={`member-error-${i}`} className={ memberErrors[i] ? "border-destructive" @@ -453,12 +462,12 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) { } /> {members.length > 1 && ( - )} - {memberErrors[i] && } + {memberErrors[i] && } {!memberErrors[i] && member && isValidStellarAddress(member) && (

✓ {tc("validAddress")} diff --git a/frontend/components/create-group/rotational-form.tsx b/frontend/components/create-group/rotational-form.tsx index d5bf786..5bf501e 100644 --- a/frontend/components/create-group/rotational-form.tsx +++ b/frontend/components/create-group/rotational-form.tsx @@ -307,8 +307,9 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) { }} onBlur={(e) => handleBlur("name", e.target.value)} aria-describedby="name-error" + aria-required="true" /> - {touched.name && } + {touched.name && }

@@ -358,8 +359,10 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) { if (touched.contributionAmount) validateField("contributionAmount", e.target.value) }} onBlur={(e) => handleBlur("contributionAmount", e.target.value)} + aria-required="true" + aria-describedby="amount-error" /> - {touched.contributionAmount && } + {touched.contributionAmount && }
@@ -435,6 +438,9 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) { placeholder={tc("addressPlaceholder")} value={member} onChange={(e) => updateMember(i, e.target.value)} + aria-label={`Member ${i + 2} address`} + aria-required="true" + aria-describedby={`member-error-${i}`} className={ memberErrors[i] ? "border-destructive" @@ -444,12 +450,12 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) { } /> {members.length > 1 && ( - )}
- {memberErrors[i] && } + {memberErrors[i] && } {!memberErrors[i] && member && isValidStellarAddress(member) && (

✓ {tc("validAddress")} diff --git a/frontend/components/create-group/target-form.tsx b/frontend/components/create-group/target-form.tsx index 2066cc4..87aed2d 100644 --- a/frontend/components/create-group/target-form.tsx +++ b/frontend/components/create-group/target-form.tsx @@ -327,8 +327,10 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) { if (touched.name) validateField("name", e.target.value) }} onBlur={(e) => handleBlur("name", e.target.value)} + aria-required="true" + aria-describedby="name-error" /> - {touched.name && } + {touched.name && }

@@ -378,8 +380,10 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) { if (touched.targetAmount) validateField("targetAmount", e.target.value) }} onBlur={(e) => handleBlur("targetAmount", e.target.value)} + aria-required="true" + aria-describedby="target-error" /> - {touched.targetAmount && } + {touched.targetAmount && }
@@ -402,6 +406,8 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) { if (touched.deadlineDays) validateField("deadlineDays", e.target.value) }} onBlur={(e) => handleBlur("deadlineDays", e.target.value)} + aria-required="true" + aria-describedby="deadline-error" /> {days > 0 && (

@@ -414,7 +420,7 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) { : t("fetchingLedger")}

)} - {touched.deadlineDays && } + {touched.deadlineDays && }
@@ -466,6 +472,9 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) { placeholder={tc("addressPlaceholder")} value={member} onChange={(e) => updateMember(i, e.target.value)} + aria-label={`Member ${i + 2} address`} + aria-required="true" + aria-describedby={`member-error-${i}`} className={ memberErrors[i] ? "border-destructive" @@ -475,12 +484,12 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) { } /> {members.length > 1 && ( - )} - {memberErrors[i] && } + {memberErrors[i] && } {!memberErrors[i] && member && isValidStellarAddress(member) && (

✓ {tc("validAddress")} diff --git a/frontend/components/dashboard/dashboard-header.tsx b/frontend/components/dashboard/dashboard-header.tsx index 9e9d51c..72dff32 100644 --- a/frontend/components/dashboard/dashboard-header.tsx +++ b/frontend/components/dashboard/dashboard-header.tsx @@ -35,6 +35,7 @@ export function DashboardHeader() { const [copied, setCopied] = useState(false) const { recentPools } = useRecentPools(address) const { notifications, initialLoading, unreadCount, markAllRead } = useNotifications(address) + const [notificationOpen, setNotificationOpen] = useState(false) const truncatedAddress = address ? `${address.slice(0, 4)}...${address.slice(-4)}` : "" @@ -100,7 +101,9 @@ export function DashboardHeader() { {/* Notification bell — only shown when wallet is connected */} {address && ( { + setNotificationOpen(open) if (open && unreadCount > 0) markAllRead() }} > @@ -110,6 +113,7 @@ export function DashboardHeader() { size="icon" className="relative" aria-label={t("notifications")} + aria-expanded={notificationOpen} > {unreadCount > 0 && ( diff --git a/frontend/components/dashboard/dashboard-tabs.tsx b/frontend/components/dashboard/dashboard-tabs.tsx index 8a1f696..9437795 100644 --- a/frontend/components/dashboard/dashboard-tabs.tsx +++ b/frontend/components/dashboard/dashboard-tabs.tsx @@ -39,64 +39,81 @@ export function DashboardTabs({ setActiveTab("create") }, [setActiveTab]) + const tabValues = ["groups", "portfolio", "explore", "create", "transactions", "analytics", "profile"]; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowRight") { + e.preventDefault(); + const currentIndex = tabValues.indexOf(activeTab); + const nextIndex = (currentIndex + 1) % tabValues.length; + setActiveTab(tabValues[nextIndex]); + document.getElementById(`tab-${tabValues[nextIndex]}`)?.focus(); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + const currentIndex = tabValues.indexOf(activeTab); + const prevIndex = (currentIndex - 1 + tabValues.length) % tabValues.length; + setActiveTab(tabValues[prevIndex]); + document.getElementById(`tab-${tabValues[prevIndex]}`)?.focus(); + } + }; + return ( - - + + {t("groups")} - + {t("portfolio")} - + {t("explore")} - + {t("create")} - + {t("transactions")} - + {t("analytics")} - + {t("profile")} - + - + - + - + - + - + - + diff --git a/frontend/components/dashboard/pool-card.tsx b/frontend/components/dashboard/pool-card.tsx index d4d327a..4c00c0c 100644 --- a/frontend/components/dashboard/pool-card.tsx +++ b/frontend/components/dashboard/pool-card.tsx @@ -1,4 +1,4 @@ -"use client" +"use client" import { Card } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -196,7 +196,18 @@ export function PoolCard({ pool }: { pool: Pool }) { return ( - + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + document.getElementById(`pool-link-${pool.id}`)?.click(); + } + }} + >

{pool.name}

@@ -264,7 +275,7 @@ export function PoolCard({ pool }: { pool: Pool }) { {t("progress")} {progress.toFixed(1)}%
-
+ - {progressLabel &&

{progressLabel}

} + {progressLabel && }
{showVersionWarning && (
@@ -280,8 +291,8 @@ export function PoolCard({ pool }: { pool: Pool }) { {t("versionWarning", { version: contractVersion })}
)} - diff --git a/frontend/components/group/group-details.tsx b/frontend/components/group/group-details.tsx index 443fd63..b1309d9 100644 --- a/frontend/components/group/group-details.tsx +++ b/frontend/components/group/group-details.tsx @@ -503,17 +503,19 @@ export function GroupDetails({ groupId, contractAddress, poolAdmin }: GroupDetai

{group.name}

- {tPool(`type.${group.type}`)} - + + {tPool(`type.${group.type}`)} + + {tPool(`status.${group.status}`)} {onchainState && ( - + {t("liveOnchain")} )} {hasAutoTrigger && ( - + {t("autoTriggerEnabled")} )} @@ -523,12 +525,13 @@ export function GroupDetails({ groupId, contractAddress, poolAdmin }: GroupDetai className={`text-xs ${ ttlDays < 7 ? "text-destructive border-destructive/40 bg-destructive/10" : "" }`} + aria-label={`State expires in ${ttlDays} days`} > {t("stateExpiresIn", { days: ttlDays })} )} {isStale && !isLoading && ( - + {t("stale")} )} @@ -536,6 +539,7 @@ export function GroupDetails({ groupId, contractAddress, poolAdmin }: GroupDetai {t("pendingEllipsis")} diff --git a/frontend/components/onboarding/onboarding-wizard.tsx b/frontend/components/onboarding/onboarding-wizard.tsx index 1987c1b..0bd1174 100644 --- a/frontend/components/onboarding/onboarding-wizard.tsx +++ b/frontend/components/onboarding/onboarding-wizard.tsx @@ -10,6 +10,7 @@ import { Progress } from "@/components/ui/progress" import { Card } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Dialog, DialogContent } from "@/components/ui/dialog" import { useStellar } from "@/components/web3-provider" import { useOnboarding } from "@/hooks/useOnboarding" import { ONBOARDING_STEPS, ONBOARDING_STEP_COUNT, completedStepCount } from "@/lib/onboarding" @@ -307,63 +308,58 @@ export function OnboardingWizard({ open, onClose }: WizardProps) { return ( {open && ( - - e.stopPropagation()} - className="w-full max-w-lg" - role="dialog" - aria-modal="true" - aria-label={t("dialogAriaLabel")} - > - - + !isOpen && skip()}> + + + + - {/* Progress bar */} -
-
- - {t("stepOf", { current: step + 1, total: ONBOARDING_STEP_COUNT })} - - {tSteps(stepName)} + {/* Progress bar */} +
+
+ + {t("stepOf", { current: step + 1, total: ONBOARDING_STEP_COUNT })} + + {tSteps(stepName)} +
+
- -
-
-

{stepCopy[step].title}

-

{stepCopy[step].subtitle}

-
+
+

{stepCopy[step].title}

+

{stepCopy[step].subtitle}

+
- - - {renderStep()} - - - - - + + + {renderStep()} + + + + + +
)}
) diff --git a/frontend/components/ui/form.tsx b/frontend/components/ui/form.tsx index 7ed3b40..b3a6271 100644 --- a/frontend/components/ui/form.tsx +++ b/frontend/components/ui/form.tsx @@ -140,10 +140,10 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) { ) } -function FieldError({ message, className }: { message?: string; className?: string }) { +function FieldError({ message, className, id }: { message?: string; className?: string; id?: string }) { if (!message) return null return ( -

+

{message}