diff --git a/app/components/BetForm.tsx b/app/components/BetForm.tsx index 85efb38..ef7a47f 100644 --- a/app/components/BetForm.tsx +++ b/app/components/BetForm.tsx @@ -29,7 +29,7 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import QuickBetPresets from "@/components/QuickBetPresets"; import KbdHint from "../../src/components/KbdHint"; import { useReducedMotion } from "@/hooks/useReducedMotion"; @@ -50,6 +50,15 @@ const BetForm: React.FC = ({ onSubmit, isLoading = false }) => { const [error, setError] = useState(null); const reducedMotion = useReducedMotion(); + /** Tracks whether the scrollable content area has been scrolled, so the + * sticky action bar can grow a divider/shadow once content scrolls behind it. */ + const [bodyScrolled, setBodyScrolled] = useState(false); + const bodyRef = useRef(null); + + const handleBodyScroll = () => { + setBodyScrolled((bodyRef.current?.scrollTop ?? 0) > 0); + }; + /** The numeric value of the current input, or null when empty / invalid. */ const numericAmount = amount !== "" && !isNaN(Number(amount)) ? Number(amount) : null; @@ -140,101 +149,129 @@ const BetForm: React.FC = ({ onSubmit, isLoading = false }) => { >
{/* - * Preset chips - * ──────────── - * flex-wrap — chips wrap on narrow viewports instead of overflowing. - * gap-2 — consistent horizontal + vertical gap between chips. - * justify-start — left-align chips; they should not stretch to fill the row. - * Each chip gets min-w-[60px] via QuickBetPresets (see component) to - * ensure a touchable target on mobile (WCAG 2.5.5). - */} - - - {/* Amount input */} -
- - + {/* + * Preset chips + * ──────────── + * flex-wrap — chips wrap on narrow viewports instead of overflowing. + * gap-2 — consistent horizontal + vertical gap between chips. + * justify-start — left-align chips; they should not stretch to fill the row. + * Each chip gets min-w-[60px] via QuickBetPresets (see component) to + * ensure a touchable target on mobile (WCAG 2.5.5). + */} + - {error && ( - - )} + Amount (XLM) + + + {error && ( + + )} +
{/* - * Submit button - * ───────────── - * Full-width on all breakpoints (w-full) — the form is already - * constrained by max-w-sm on the wrapper, so the button never - * becomes comically wide. - * min-h-[44px] — WCAG 2.5.5 minimum touch target height. + * ── Sticky action bar ────────────────────────────────────────── + * `sticky bottom-0` pins it to the bottom of the form (the nearest + * scrolling ancestor is the overflow-y-auto container above) as the + * content scrolls beneath it. Grows a top border and shadow once the + * body has actually been scrolled, so it reads as a distinct bar + * floating over content rather than empty space when everything + * already fits without scrolling. */} - + + ); @@ -315,4 +352,4 @@ export const BetFormSkeleton: React.FC = () => { /> ); -}; +}; \ No newline at end of file diff --git a/app/components/__tests__/BetForm.sticky-action-bar.test.tsx b/app/components/__tests__/BetForm.sticky-action-bar.test.tsx new file mode 100644 index 0000000..b4f2fbc --- /dev/null +++ b/app/components/__tests__/BetForm.sticky-action-bar.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import BetForm from "../BetForm"; + +describe("BetForm sticky action bar", () => { + it("renders the sticky action bar with the Place Bet button", () => { + render(); + const bar = screen.getByTestId("betform-action-bar"); + expect(bar).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /place bet/i })).toBeInTheDocument(); + }); + + it("keeps the action bar sticky to the bottom of the scrollable content", () => { + render(); + const bar = screen.getByTestId("betform-action-bar"); + expect(bar).toHaveClass("sticky"); + expect(bar).toHaveClass("bottom-0"); + }); + + it("grows a divider/shadow once the scrollable body has been scrolled", () => { + render(); + const bar = screen.getByTestId("betform-action-bar"); + const body = bar.previousElementSibling as HTMLElement; + + // Initially no border/shadow + expect(bar).not.toHaveClass("border-t"); + + // Simulate scroll + Object.defineProperty(body, "scrollTop", { value: 40, configurable: true }); + fireEvent.scroll(body); + + // Now border-t appears + expect(bar).toHaveClass("border-t"); + }); + + it("does not show the border when the body has not been scrolled", () => { + render(); + const bar = screen.getByTestId("betform-action-bar"); + const body = bar.previousElementSibling as HTMLElement; + + Object.defineProperty(body, "scrollTop", { value: 0, configurable: true }); + fireEvent.scroll(body); + + expect(bar).not.toHaveClass("border-t"); + }); + + it("calls onSubmit when the Place Bet button is clicked", () => { + const onSubmit = jest.fn(); + render(); + + // Set a valid amount first + const input = screen.getByPlaceholderText("Enter amount"); + fireEvent.change(input, { target: { value: "10" } }); + + // Click the submit button + fireEvent.click(screen.getByRole("button", { name: /place bet/i })); + expect(onSubmit).toHaveBeenCalledWith(10); + }); +}); \ No newline at end of file