2929
3030"use client" ;
3131
32- import React , { useState , useEffect } from "react" ;
32+ import React , { useState , useEffect , useRef } from "react" ;
3333import QuickBetPresets from "@/components/QuickBetPresets" ;
3434import KbdHint from "../../src/components/KbdHint" ;
3535import { 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+ } ;
0 commit comments