Skip to content

Commit 654c985

Browse files
samejrmatt-aitken
authored andcommitted
Move stepper into its own component
1 parent a86c0f2 commit 654c985

File tree

2 files changed

+275
-258
lines changed

2 files changed

+275
-258
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
2+
import { type ChangeEvent, useRef } from "react";
3+
import { cn } from "~/utils/cn";
4+
5+
type InputNumberStepperProps = JSX.IntrinsicElements["input"] & {
6+
step?: number;
7+
min?: number;
8+
max?: number;
9+
round?: boolean;
10+
controlSize?: "base" | "large";
11+
};
12+
13+
export function InputNumberStepper({
14+
value,
15+
onChange,
16+
step = 50,
17+
min,
18+
max,
19+
round = true,
20+
controlSize = "base",
21+
name,
22+
id,
23+
disabled = false,
24+
readOnly = false,
25+
className,
26+
placeholder = "Type a number",
27+
}: InputNumberStepperProps) {
28+
const inputRef = useRef<HTMLInputElement>(null);
29+
30+
const handleStepUp = () => {
31+
if (!inputRef.current || disabled) return;
32+
33+
// If rounding is enabled, ensure we start from a rounded base before stepping
34+
if (round) {
35+
// If field is empty, treat as 0 (or min if provided) before stepping up
36+
if (inputRef.current.value === "") {
37+
inputRef.current.value = String(min ?? 0);
38+
} else {
39+
commitRoundedFromInput();
40+
}
41+
}
42+
inputRef.current.stepUp();
43+
const event = new Event("change", { bubbles: true });
44+
inputRef.current.dispatchEvent(event);
45+
};
46+
47+
const handleStepDown = () => {
48+
if (!inputRef.current || disabled) return;
49+
50+
// If rounding is enabled, ensure we start from a rounded base before stepping
51+
if (round) {
52+
// If field is empty, treat as 0 (or min if provided) before stepping down
53+
if (inputRef.current.value === "") {
54+
inputRef.current.value = String(min ?? 0);
55+
} else {
56+
commitRoundedFromInput();
57+
}
58+
}
59+
inputRef.current.stepDown();
60+
const event = new Event("change", { bubbles: true });
61+
inputRef.current.dispatchEvent(event);
62+
};
63+
64+
const numericValue = value === "" ? NaN : (value as number);
65+
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
66+
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
67+
68+
function clamp(val: number): number {
69+
if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0;
70+
let next = val;
71+
if (min !== undefined) next = Math.max(min, next);
72+
if (max !== undefined) next = Math.min(max, next);
73+
return next;
74+
}
75+
76+
function roundToStep(val: number): number {
77+
if (step <= 0) return val;
78+
const base = min ?? 0;
79+
const shifted = val - base;
80+
const quotient = shifted / step;
81+
const floored = Math.floor(quotient);
82+
const ceiled = Math.ceil(quotient);
83+
const down = base + floored * step;
84+
const up = base + ceiled * step;
85+
const distDown = Math.abs(val - down);
86+
const distUp = Math.abs(up - val);
87+
return distUp < distDown ? up : down;
88+
}
89+
90+
function commitRoundedFromInput() {
91+
if (!inputRef.current || disabled || readOnly) return;
92+
const el = inputRef.current;
93+
const raw = el.value;
94+
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
95+
const numeric = Number(raw);
96+
if (Number.isNaN(numeric)) return; // ignore non-numeric
97+
const rounded = clamp(roundToStep(numeric));
98+
if (String(rounded) === String(value)) return;
99+
// Update the real input's value for immediate UI feedback
100+
el.value = String(rounded);
101+
// Invoke consumer onChange with the real element as target/currentTarget
102+
onChange?.({
103+
target: el,
104+
currentTarget: el,
105+
} as unknown as ChangeEvent<HTMLInputElement>);
106+
}
107+
108+
const sizeStyles = {
109+
base: {
110+
container: "h-9",
111+
input: "text-sm px-3",
112+
button: "size-6",
113+
icon: "size-3.5",
114+
gap: "gap-1 pr-1.5",
115+
},
116+
large: {
117+
container: "h-11 rounded-md",
118+
input: "text-base px-3.5",
119+
button: "size-8",
120+
icon: "size-5",
121+
gap: "gap-[0.3125rem] pr-[0.3125rem]",
122+
},
123+
} as const;
124+
125+
const size = sizeStyles[controlSize];
126+
127+
return (
128+
<div
129+
className={cn(
130+
"flex items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80",
131+
size.container,
132+
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link",
133+
disabled && "cursor-not-allowed opacity-50",
134+
className
135+
)}
136+
>
137+
<input
138+
ref={inputRef}
139+
type="number"
140+
id={id}
141+
name={name}
142+
value={value}
143+
placeholder={placeholder}
144+
onChange={(e) => {
145+
// Allow empty string to pass through so user can clear the field
146+
if (e.currentTarget.value === "") {
147+
// reflect emptiness in the input and notify consumer as empty
148+
if (inputRef.current) inputRef.current.value = "";
149+
onChange?.({
150+
target: e.currentTarget,
151+
currentTarget: e.currentTarget,
152+
} as ChangeEvent<HTMLInputElement>);
153+
return;
154+
}
155+
onChange?.(e);
156+
}}
157+
onBlur={(e) => {
158+
// If blur is caused by clicking our step buttons, we prevent pointerdown
159+
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
160+
if (round) commitRoundedFromInput();
161+
}}
162+
onKeyDown={(e) => {
163+
if (e.key === "Enter" && round) {
164+
e.preventDefault();
165+
commitRoundedFromInput();
166+
}
167+
}}
168+
step={step}
169+
min={min}
170+
max={max}
171+
disabled={disabled}
172+
readOnly={readOnly}
173+
className={cn(
174+
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
175+
size.input,
176+
// Hide number input arrows
177+
"[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
178+
)}
179+
/>
180+
181+
<div className={cn("flex items-center", size.gap)}>
182+
<button
183+
type="button"
184+
onClick={handleStepDown}
185+
onPointerDown={(e) => e.preventDefault()}
186+
disabled={disabled || isMinDisabled}
187+
aria-label={`Decrease by ${step}`}
188+
className={cn(
189+
"flex items-center justify-center rounded border border-error/30 bg-error/20 transition",
190+
size.button,
191+
"hover:border-error/50 hover:bg-error/30",
192+
"disabled:cursor-not-allowed disabled:opacity-40",
193+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
194+
)}
195+
>
196+
<MinusIcon className={cn("text-error", size.icon)} />
197+
</button>
198+
199+
<button
200+
type="button"
201+
onClick={handleStepUp}
202+
onPointerDown={(e) => e.preventDefault()}
203+
disabled={disabled || isMaxDisabled}
204+
aria-label={`Increase by ${step}`}
205+
className={cn(
206+
"flex items-center justify-center rounded border border-success/30 bg-success/10 transition",
207+
size.button,
208+
"hover:border-success/40 hover:bg-success/20",
209+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
210+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
211+
)}
212+
>
213+
<PlusIcon className={cn("text-success", size.icon)} />
214+
</button>
215+
</div>
216+
</div>
217+
);
218+
}

0 commit comments

Comments
 (0)