-
-
Notifications
You must be signed in to change notification settings - Fork 148
feat(client): one sanitized numeric box for all three amount prompts #7019
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
matthewevans
merged 9 commits into
phase-rs:main
from
lgray:fix/amount-prompt-numeric-input
Aug 5, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
56164e8
feat(client): replace the PayAmountChoice slider with a sanitized numβ¦
lgray 5dbc9cc
feat(client): adopt the shared amount box in the ChooseX and Assist pβ¦
lgray 501d94c
fix(client): address final review-impl findings on the amount box
lgray 35effbd
fix(client): key amount-prompt resets on prompt identity, and widen tβ¦
lgray 95af45c
fix(client): real 44px targets, and key resets on the acting seat too
lgray 45a5991
fix(client): cite CR 723.5 for turn control, and drop two stale claims
lgray dbebc76
test(client): pin that AmountInput does not re-guard Enter
lgray e7b3a1f
docs(client): repoint the reset-dep evidence at a case that actually β¦
lgray 8955823
docs(client): finish the sweep the previous commit started
lgray File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| import { useId } from "react"; | ||
| import { useTranslation } from "react-i18next"; | ||
|
|
||
| import { gameButtonClass } from "../ui/buttonStyles.ts"; | ||
|
|
||
| /** | ||
| * Shared bounded-amount control for the engine's amount prompts | ||
| * (PayAmountChoice / ChooseXValue / AssistPayment). | ||
| * | ||
| * The `[min, max]` window is ENGINE-OWNED and arrives as props β this component holds no bound of | ||
| * its own, no default, and no fallback. `parseAmount` is the single sanitization authority; it | ||
| * REJECTS (returns null) rather than coercing, so a player never submits a number they did not type. | ||
| */ | ||
|
|
||
| /** Digit reading of `raw`, ignoring the window. Recovery uses this; SUBMISSION uses `parseAmount`. */ | ||
| function digitsOf(raw: string): number | null { | ||
| return /^\d+$/.test(raw) ? Number(raw) : null; | ||
| } | ||
|
|
||
| export function parseAmount(raw: string, min: number, max: number): number | null { | ||
| // Digits only. `Number()` alone is NOT sufficient: MEASURED, Number("") === 0, | ||
| // Number(" 7 ") === 7, Number("1.5") === 1.5, Number("1e3") === 1000, Number("+2") === 2 and | ||
| // Number("0x10") === 16 all land INSIDE a typical window. | ||
| const value = digitsOf(raw); | ||
| return value !== null && value >= min && value <= max ? value : null; | ||
| } | ||
|
|
||
| export interface AmountInputLabels { | ||
| /** aria-label for the numeric text box. */ | ||
| input: string; | ||
| /** aria-label for the β stepper. */ | ||
| decrease: string; | ||
| /** aria-label for the + stepper. */ | ||
| increase: string; | ||
| } | ||
|
|
||
| export function AmountInput({ | ||
| raw, | ||
| onRawChange, | ||
| min, | ||
| max, | ||
| onSubmit, | ||
| labels, | ||
| }: { | ||
| raw: string; | ||
| onRawChange: (raw: string) => void; | ||
| min: number; | ||
| max: number; | ||
| /** Called on Enter. MUST itself reject an invalid amount β AmountInput deliberately does not | ||
| * re-guard, because a second guard would make the caller's guard unobservable and untestable. */ | ||
| onSubmit: () => void; | ||
| labels: AmountInputLabels; | ||
| }) { | ||
| const { t } = useTranslation("game"); | ||
| const amount = parseAmount(raw, min, max); | ||
| const hintId = useId(); | ||
| const errorId = useId(); | ||
|
|
||
| // Recovery anchor. With the slider deleted the steppers are the only non-typing way out of an | ||
| // invalid entry, so they stay LIVE while `amount === null` and snap back into [min, max]. They | ||
| // step from the DIGIT reading, not from `amount`: `parseAmount` collapses "junk" and "out of | ||
| // range" into the same null, so stepping from `amount ?? min` would throw away a perfectly | ||
| // readable 1001 and jump to min. `parseAmount` gates SUBMISSION; `step` performs RECOVERY | ||
| // toward the window. | ||
| const step = (delta: number) => | ||
| onRawChange(String(Math.min(Math.max((digitsOf(raw) ?? min) + delta, min), max))); | ||
| const decDisabled = amount !== null && amount <= min; | ||
| const incDisabled = amount !== null && amount >= max; | ||
|
|
||
| // ponytail: no showSlider/showSteppers flag β the slider is deleted, not configurable. | ||
| // ponytail: no role="alert" β assertive per-keystroke announcements are the anti-pattern; | ||
| // aria-invalid + aria-describedby is the association. | ||
| // ponytail: no pattern="[0-9]*" β inputMode="numeric" carries the modern-iOS keypad; add back | ||
| // only on a legacy-iOS report. | ||
| // ponytail: the null-guard lives once, in the caller's handleCommit β a second guard in | ||
| // onKeyDown would make it unobservable. | ||
| // role="spinbutton" IS carried, reversing an earlier note here that claimed no in-repo | ||
| // precedent and unwanted aria-value* upkeep. Both premises were wrong: `ManaCurve.tsx` already | ||
| // uses aria-valuenow, and the three controls this box replaced announced their value NATIVELY | ||
| // (`type="range"` β role slider, `type="number"` β role spinbutton). Dropping to a bare | ||
| // `type="text"` therefore made the ACCEPTED amount inaudible β pressing +/β or the arrow keys | ||
| // mutated a value nothing exposed. The role is descriptive rather than decorative because the | ||
| // box implements the pattern's CORE keyboard interaction (ArrowUp/ArrowDown step, clamped to | ||
| // the window) β not the full APG list. Home/End (jump to min/max) are deliberately NOT | ||
| // remapped: the host is a real editable text field where they carry load-bearing caret | ||
| // semantics, and native `<input type="number">` β whose implicit role is already spinbutton β | ||
| // does not remap them either. | ||
| // `aria-valuenow` uses the VALIDATED amount, so it is absent while the entry is out of range | ||
| // rather than contradicting aria-valuemin/max; `aria-invalid` carries that state instead. | ||
| return ( | ||
| <div className="mb-4 px-2"> | ||
| <div className="flex items-center justify-center gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => step(-1)} | ||
| disabled={decDisabled} | ||
| aria-label={labels.decrease} | ||
| className={gameButtonClass({ | ||
| tone: "neutral", | ||
| size: "xs", | ||
| disabled: decDisabled, | ||
| // 44px REAL size, not a 36px box with an expanded `::before`. The pseudo-element | ||
| // trick (as in `board/ManualManaToggle`) measured 42px here, not 44: `gameButtonClass` | ||
| // adds `border`, and an absolutely positioned pseudo resolves against its ancestor's | ||
| // PADDING box (36 β 2Γ1 = 34), so `-inset-1` yields 34 + 8. It works in | ||
| // `ManualManaToggle` only because that control uses `ring-1`, which adds no layout | ||
| // border. A size that must be derived to be checked is a size that will silently | ||
| // regress; these steppers are the only non-typing recovery path out of an invalid | ||
| // entry, so they get the boring, directly-readable 44px. | ||
| // No `px-0`: `SIZE_CLASSES.xs` emits `px-2.5` later in the compiled sheet and wins, | ||
| // so a `px-0` here would read as load-bearing while doing nothing. `w-11` pins the | ||
| // border box at 44px regardless of padding (border-box sizing). | ||
| className: "h-11 w-11 text-base", | ||
| })} | ||
| > | ||
| β | ||
| </button> | ||
| <input | ||
| type="text" | ||
| inputMode="numeric" | ||
| autoComplete="off" | ||
| value={raw} | ||
| onChange={(e) => onRawChange(e.target.value)} | ||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter") { | ||
| e.preventDefault(); | ||
| onSubmit(); | ||
| return; | ||
| } | ||
| // type="text" has no native stepping; the accessibility floor requires arrows to step. | ||
| if (e.key === "ArrowUp") { | ||
| e.preventDefault(); | ||
| step(1); | ||
| } else if (e.key === "ArrowDown") { | ||
| e.preventDefault(); | ||
| step(-1); | ||
| } | ||
| }} | ||
| aria-label={labels.input} | ||
| role="spinbutton" | ||
| aria-valuenow={amount ?? undefined} | ||
| aria-valuemin={min} | ||
| aria-valuemax={max} | ||
| aria-invalid={amount === null} | ||
| // The window is ANNOUNCED, not merely displayed. `type="range"` announced min/max/now | ||
| // natively; a text box announces nothing, so the range hint is permanently associated | ||
| // and the error message is appended to it while invalid. | ||
| aria-describedby={amount === null ? `${hintId} ${errorId}` : hintId} | ||
| // `w-24` not `w-20`: four digits must fit for the 1000 case. `h-11` (44px) because this | ||
| // is the PRIMARY tap target of the control β fixing only the steppers would leave the | ||
| // main one short. The `::before` idiom cannot be used here regardless: `<input>` is a | ||
| // replaced element and renders no pseudo-elements. | ||
| className={`h-11 w-24 rounded-lg border bg-gray-950/80 px-2 text-center font-mono text-base font-semibold shadow-inner outline-none transition focus:ring-2 ${ | ||
| amount === null | ||
| ? "border-red-400/60 text-red-200 focus:ring-red-400/30" | ||
| : "border-cyan-400/30 text-cyan-100 focus:ring-cyan-400/30" | ||
| }`} | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={() => step(1)} | ||
| disabled={incDisabled} | ||
| aria-label={labels.increase} | ||
| className={gameButtonClass({ | ||
| tone: "neutral", | ||
| size: "xs", | ||
| disabled: incDisabled, | ||
| // 44px REAL size, not a 36px box with an expanded `::before`. The pseudo-element | ||
| // trick (as in `board/ManualManaToggle`) measured 42px here, not 44: `gameButtonClass` | ||
| // adds `border`, and an absolutely positioned pseudo resolves against its ancestor's | ||
| // PADDING box (36 β 2Γ1 = 34), so `-inset-1` yields 34 + 8. It works in | ||
| // `ManualManaToggle` only because that control uses `ring-1`, which adds no layout | ||
| // border. A size that must be derived to be checked is a size that will silently | ||
| // regress; these steppers are the only non-typing recovery path out of an invalid | ||
| // entry, so they get the boring, directly-readable 44px. | ||
| // No `px-0`: `SIZE_CLASSES.xs` emits `px-2.5` later in the compiled sheet and wins, | ||
| // so a `px-0` here would read as load-bearing while doing nothing. `w-11` pins the | ||
| // border box at 44px regardless of padding (border-box sizing). | ||
| className: "h-11 w-11 text-base", | ||
| })} | ||
| > | ||
| + | ||
| </button> | ||
| <span id={hintId} className="shrink-0 text-xs text-gray-500"> | ||
| {min > 0 ? t("mana.minMax", { min, max }) : t("mana.maxOnly", { max })} | ||
| </span> | ||
| </div> | ||
|
|
||
| {/* PERMANENTLY MOUNTED, and a live region β both properties are load-bearing. | ||
| `aria-invalid`/`aria-describedby` are resolved by a screen reader when focus ARRIVES at | ||
| the box, but here they flip while focus is already inside it, so association alone | ||
| announces nothing: the player would have to tab away and back to learn the entry was | ||
| refused. Mounting the node INTO a live region is equally unreliable (regions announce | ||
| MUTATIONS of existing content), so the region pre-exists and only its text changes. | ||
| `role="status"` (polite) rather than "alert": the entry is being corrected mid-typing | ||
| and must not interrupt what is already being read. `min-h-4` reserves the line so | ||
| recovering from an invalid entry does not shift the panel under the pointer. */} | ||
| <p | ||
| id={errorId} | ||
| role="status" | ||
| aria-live="polite" | ||
| className="mt-2 min-h-4 text-center text-xs text-red-300" | ||
| > | ||
| {amount === null ? t("mana.amountOutOfRange", { min, max }) : ""} | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.