Skip to content

Commit 06579fa

Browse files
committed
feat(bet-form): add sticky action bar on BetForm while scrolling
Adds a sticky bottom action bar to BetForm that stays pinned to the bottom of the form while the preset chips and amount input scroll within a constrained container. - Wraps QuickBetPresets and the amount input in a max-h overflow-y-auto scrollable container so the form stays compact on small viewports. - The submit button (Place Bet) is now wrapped in a sticky bottom-0 action bar that remains reachable while the content scrolls. - Grows a top border + shadow once the body has been scrolled (tracked via onScroll), so it reads as a floating bar over content rather than empty space when everything fits without scrolling. - Respects reducedMotion (suppresses the shadow transition). - Adds focused unit tests for the sticky action bar behavior. Closes #808
1 parent bd18039 commit 06579fa

2 files changed

Lines changed: 182 additions & 86 deletions

File tree

app/components/BetForm.tsx

Lines changed: 123 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
"use client";
3131

32-
import React, { useState, useEffect } from "react";
32+
import React, { useState, useEffect, useRef } from "react";
3333
import QuickBetPresets from "@/components/QuickBetPresets";
3434
import KbdHint from "../../src/components/KbdHint";
3535
import { useReducedMotion } from "@/hooks/useReducedMotion";
@@ -50,6 +50,15 @@ const BetForm: React.FC<BetFormProps> = ({ onSubmit, isLoading = false }) => {
5050
const [error, setError] = useState<string | null>(null);
5151
const reducedMotion = useReducedMotion();
5252

53+
/** Tracks whether the scrollable content area has been scrolled, so the
54+
* sticky action bar can grow a divider/shadow once content scrolls behind it. */
55+
const [bodyScrolled, setBodyScrolled] = useState(false);
56+
const bodyRef = useRef<HTMLDivElement>(null);
57+
58+
const handleBodyScroll = () => {
59+
setBodyScrolled((bodyRef.current?.scrollTop ?? 0) > 0);
60+
};
61+
5362
/** The numeric value of the current input, or null when empty / invalid. */
5463
const numericAmount = amount !== "" && !isNaN(Number(amount)) ? Number(amount) : null;
5564

@@ -140,101 +149,129 @@ const BetForm: React.FC<BetFormProps> = ({ onSubmit, isLoading = false }) => {
140149
>
141150
<div className="flex flex-col gap-3 sm:gap-4">
142151
{/*
143-
* Preset chips
144-
* ────────────
145-
* flex-wrap — chips wrap on narrow viewports instead of overflowing.
146-
* gap-2 — consistent horizontal + vertical gap between chips.
147-
* justify-start — left-align chips; they should not stretch to fill the row.
148-
* Each chip gets min-w-[60px] via QuickBetPresets (see component) to
149-
* ensure a touchable target on mobile (WCAG 2.5.5).
150-
*/}
151-
<QuickBetPresets
152-
selectedAmount={numericAmount}
153-
onSelect={handlePresetSelect}
154-
/>
155-
156-
{/* Amount input */}
157-
<div className="flex flex-col gap-1">
158-
<label
159-
htmlFor="bet-amount"
160-
className="text-label text-foreground"
161-
>
162-
Amount (XLM)
163-
</label>
164-
<input
165-
id="bet-amount"
166-
type="number"
167-
min="0.0000001"
168-
step="any"
169-
value={amount}
170-
onChange={handleAmountChange}
171-
placeholder="Enter amount"
172-
aria-describedby={error ? "bet-amount-error" : undefined}
173-
aria-invalid={error ? true : undefined}
174-
/*
175-
* Responsive padding:
176-
* px-3 py-2 on mobile — compact but comfortable (38 px height).
177-
* sm:px-4 sm:py-2.5 — slightly more breathing room on ≥ 640 px.
178-
*/
179-
className={[
180-
"w-full rounded-md border",
181-
"px-3 py-2 sm:px-4 sm:py-2.5",
182-
"text-body-sm tabular-nums",
183-
"bg-background text-foreground placeholder:text-muted-foreground",
184-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
185-
"focus-visible:ring-offset-background",
186-
"disabled:cursor-not-allowed disabled:opacity-50",
187-
error
188-
? "border-destructive focus-visible:ring-destructive"
189-
: "border-border",
190-
].join(" ")}
152+
* Scrollable content area
153+
* ──────────────────────
154+
* Wraps the preset chips and amount input in a max-height container
155+
* so that on constrained viewports the content scrolls independently
156+
* while the sticky action bar below remains reachable.
157+
*/
158+
}
159+
<div
160+
ref={bodyRef}
161+
onScroll={handleBodyScroll}
162+
className="max-h-[min(60vh,26rem)] overflow-y-auto flex flex-col gap-3 sm:gap-4"
163+
>
164+
{/*
165+
* Preset chips
166+
* ────────────
167+
* flex-wrap — chips wrap on narrow viewports instead of overflowing.
168+
* gap-2 — consistent horizontal + vertical gap between chips.
169+
* justify-start — left-align chips; they should not stretch to fill the row.
170+
* Each chip gets min-w-[60px] via QuickBetPresets (see component) to
171+
* ensure a touchable target on mobile (WCAG 2.5.5).
172+
*/}
173+
<QuickBetPresets
174+
selectedAmount={numericAmount}
175+
onSelect={handlePresetSelect}
191176
/>
192-
{error && (
193-
<p
194-
id="bet-amount-error"
195-
role="alert"
196-
className="text-body-sm text-destructive"
177+
178+
{/* Amount input */}
179+
<div className="flex flex-col gap-1">
180+
<label
181+
htmlFor="bet-amount"
182+
className="text-label text-foreground"
197183
>
198-
{error}
199-
</p>
200-
)}
184+
Amount (XLM)
185+
</label>
186+
<input
187+
id="bet-amount"
188+
type="number"
189+
min="0.0000001"
190+
step="any"
191+
value={amount}
192+
onChange={handleAmountChange}
193+
placeholder="Enter amount"
194+
aria-describedby={error ? "bet-amount-error" : undefined}
195+
aria-invalid={error ? true : undefined}
196+
/*
197+
* Responsive padding:
198+
* px-3 py-2 on mobile — compact but comfortable (38 px height).
199+
* sm:px-4 sm:py-2.5 — slightly more breathing room on ≥ 640 px.
200+
*/
201+
className={[
202+
"w-full rounded-md border",
203+
"px-3 py-2 sm:px-4 sm:py-2.5",
204+
"text-body-sm tabular-nums",
205+
"bg-background text-foreground placeholder:text-muted-foreground",
206+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
207+
"focus-visible:ring-offset-background",
208+
"disabled:cursor-not-allowed disabled:opacity-50",
209+
error
210+
? "border-destructive focus-visible:ring-destructive"
211+
: "border-border",
212+
].join(" ")}
213+
/>
214+
{error && (
215+
<p
216+
id="bet-amount-error"
217+
role="alert"
218+
className="text-body-sm text-destructive"
219+
>
220+
{error}
221+
</p>
222+
)}
223+
</div>
201224
</div>
202225

203226
{/*
204-
* Submit button
205-
* ─────────────
206-
* Full-width on all breakpoints (w-full) — the form is already
207-
* constrained by max-w-sm on the wrapper, so the button never
208-
* becomes comically wide.
209-
* min-h-[44px] — WCAG 2.5.5 minimum touch target height.
227+
* ── Sticky action bar ──────────────────────────────────────────
228+
* `sticky bottom-0` pins it to the bottom of the form (the nearest
229+
* scrolling ancestor is the overflow-y-auto container above) as the
230+
* content scrolls beneath it. Grows a top border and shadow once the
231+
* body has actually been scrolled, so it reads as a distinct bar
232+
* floating over content rather than empty space when everything
233+
* already fits without scrolling.
210234
*/}
211-
<button
212-
type="submit"
235+
<div
236+
data-testid="betform-action-bar"
213237
className={[
214-
"w-full min-h-[44px] rounded-md",
215-
"px-4 py-2 sm:py-2.5",
216-
"text-body-sm font-semibold",
217-
"flex items-center justify-center gap-2",
218-
"bg-primary text-primary-foreground",
219-
"hover:bg-primary/90",
220-
!reducedMotion && "transition-colors duration-150",
221-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
222-
"focus-visible:ring-offset-background",
238+
"sticky bottom-0 bg-background z-10 pt-3",
239+
bodyScrolled
240+
? "border-t border-border shadow-[0_-4px_6px_-4px_rgba(0,0,0,0.1)]"
241+
: "",
242+
reducedMotion ? "transition-none" : "transition-shadow",
223243
]
224244
.filter(Boolean)
225245
.join(" ")}
226246
>
227-
<span>Place Bet</span>
228-
{/* Keyboard shortcut hint — hidden on narrow screens to save space */}
229-
<span className="hidden sm:flex items-center gap-1 opacity-80">
230-
<KbdHint className="bg-primary-foreground/20 text-primary-foreground border-transparent">
231-
232-
</KbdHint>
233-
<KbdHint className="bg-primary-foreground/20 text-primary-foreground border-transparent">
234-
235-
</KbdHint>
236-
</span>
237-
</button>
247+
<button
248+
type="submit"
249+
className={[
250+
"w-full min-h-[44px] rounded-md",
251+
"px-4 py-2 sm:py-2.5",
252+
"text-body-sm font-semibold",
253+
"flex items-center justify-center gap-2",
254+
"bg-primary text-primary-foreground",
255+
"hover:bg-primary/90",
256+
!reducedMotion && "transition-colors duration-150",
257+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
258+
"focus-visible:ring-offset-background",
259+
]
260+
.filter(Boolean)
261+
.join(" ")}
262+
>
263+
<span>Place Bet</span>
264+
{/* Keyboard shortcut hint — hidden on narrow screens to save space */}
265+
<span className="hidden sm:flex items-center gap-1 opacity-80">
266+
<KbdHint className="bg-primary-foreground/20 text-primary-foreground border-transparent">
267+
268+
</KbdHint>
269+
<KbdHint className="bg-primary-foreground/20 text-primary-foreground border-transparent">
270+
271+
</KbdHint>
272+
</span>
273+
</button>
274+
</div>
238275
</div>
239276
</form>
240277
);
@@ -315,4 +352,4 @@ export const BetFormSkeleton: React.FC = () => {
315352
/>
316353
</div>
317354
);
318-
};
355+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from "react";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
import BetForm from "../BetForm";
4+
5+
describe("BetForm sticky action bar", () => {
6+
it("renders the sticky action bar with the Place Bet button", () => {
7+
render(<BetForm />);
8+
const bar = screen.getByTestId("betform-action-bar");
9+
expect(bar).toBeInTheDocument();
10+
expect(screen.getByRole("button", { name: /place bet/i })).toBeInTheDocument();
11+
});
12+
13+
it("keeps the action bar sticky to the bottom of the scrollable content", () => {
14+
render(<BetForm />);
15+
const bar = screen.getByTestId("betform-action-bar");
16+
expect(bar).toHaveClass("sticky");
17+
expect(bar).toHaveClass("bottom-0");
18+
});
19+
20+
it("grows a divider/shadow once the scrollable body has been scrolled", () => {
21+
render(<BetForm />);
22+
const bar = screen.getByTestId("betform-action-bar");
23+
const body = bar.previousElementSibling as HTMLElement;
24+
25+
// Initially no border/shadow
26+
expect(bar).not.toHaveClass("border-t");
27+
28+
// Simulate scroll
29+
Object.defineProperty(body, "scrollTop", { value: 40, configurable: true });
30+
fireEvent.scroll(body);
31+
32+
// Now border-t appears
33+
expect(bar).toHaveClass("border-t");
34+
});
35+
36+
it("does not show the border when the body has not been scrolled", () => {
37+
render(<BetForm />);
38+
const bar = screen.getByTestId("betform-action-bar");
39+
const body = bar.previousElementSibling as HTMLElement;
40+
41+
Object.defineProperty(body, "scrollTop", { value: 0, configurable: true });
42+
fireEvent.scroll(body);
43+
44+
expect(bar).not.toHaveClass("border-t");
45+
});
46+
47+
it("calls onSubmit when the Place Bet button is clicked", () => {
48+
const onSubmit = jest.fn();
49+
render(<BetForm onSubmit={onSubmit} />);
50+
51+
// Set a valid amount first
52+
const input = screen.getByPlaceholderText("Enter amount");
53+
fireEvent.change(input, { target: { value: "10" } });
54+
55+
// Click the submit button
56+
fireEvent.click(screen.getByRole("button", { name: /place bet/i }));
57+
expect(onSubmit).toHaveBeenCalledWith(10);
58+
});
59+
});

0 commit comments

Comments
 (0)