Skip to content

Commit cdb2e72

Browse files
samejrmatt-aitken
authored andcommitted
Show placeholder if you delete all numbers
1 parent a47f1ca commit cdb2e72

File tree

1 file changed

+103
-15
lines changed
  • apps/webapp/app/routes/storybook.stepper

1 file changed

+103
-15
lines changed

apps/webapp/app/routes/storybook.stepper/route.tsx

Lines changed: 103 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { Header2 } from "~/components/primitives/Headers";
44
import { cn } from "~/utils/cn";
55

66
export default function Story() {
7-
const [value1, setValue1] = useState(0);
8-
const [value2, setValue2] = useState(100);
9-
const [value3, setValue3] = useState(0);
7+
const [value1, setValue1] = useState<number | "">(0);
8+
const [value2, setValue2] = useState<number | "">(100);
9+
const [value3, setValue3] = useState<number | "">(0);
1010

1111
return (
1212
<div className="grid h-full w-full place-items-center">
@@ -17,19 +17,20 @@ export default function Story() {
1717
<label className="text-sm text-text-dimmed">Size: base (default), Step: 75</label>
1818
<InputStepper
1919
value={value1}
20-
onChange={(e) => setValue1(Number(e.target.value))}
20+
onChange={(e) => setValue1(e.target.value === "" ? "" : Number(e.target.value))}
2121
step={75}
2222
/>
2323
</div>
2424

2525
<div className="flex flex-col gap-2">
2626
<label className="text-sm text-text-dimmed">
27-
Size: base (default), Step: 50, Max: 1000
27+
Size: base (default), Step: 50, Min: 0, Max: 1000
2828
</label>
2929
<InputStepper
3030
value={value2}
31-
onChange={(e) => setValue2(Number(e.target.value))}
31+
onChange={(e) => setValue2(e.target.value === "" ? "" : Number(e.target.value))}
3232
step={50}
33+
min={0}
3334
max={1000}
3435
/>
3536
</div>
@@ -38,7 +39,7 @@ export default function Story() {
3839
<label className="text-sm text-text-dimmed">Disabled state</label>
3940
<InputStepper
4041
value={value3}
41-
onChange={(e) => setValue3(Number(e.target.value))}
42+
onChange={(e) => setValue3(e.target.value === "" ? "" : Number(e.target.value))}
4243
step={50}
4344
disabled
4445
/>
@@ -49,16 +50,18 @@ export default function Story() {
4950
}
5051

5152
interface InputStepperProps {
52-
value: number;
53+
value: number | "";
5354
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
5455
step?: number;
5556
min?: number;
5657
max?: number;
58+
round?: boolean;
5759
name?: string;
5860
id?: string;
5961
disabled?: boolean;
6062
readOnly?: boolean;
6163
className?: string;
64+
placeholder?: string;
6265
}
6366

6467
function InputStepper({
@@ -67,36 +70,95 @@ function InputStepper({
6770
step = 50,
6871
min,
6972
max,
73+
round = true,
7074
name,
7175
id,
7276
disabled = false,
7377
readOnly = false,
7478
className,
79+
placeholder = "Type a number",
7580
}: InputStepperProps) {
7681
const inputRef = useRef<HTMLInputElement>(null);
7782

7883
const handleStepUp = () => {
7984
if (!inputRef.current || disabled) return;
8085

86+
// If rounding is enabled, ensure we start from a rounded base before stepping
87+
if (round) {
88+
// If field is empty, treat as 0 (or min if provided) before stepping up
89+
if (inputRef.current.value === "") {
90+
inputRef.current.value = String(min ?? 0);
91+
} else {
92+
commitRoundedFromInput();
93+
}
94+
}
8195
inputRef.current.stepUp();
82-
83-
// Dispatch a native change event so the onChange handler is called
8496
const event = new Event("change", { bubbles: true });
8597
inputRef.current.dispatchEvent(event);
8698
};
8799

88100
const handleStepDown = () => {
89101
if (!inputRef.current || disabled) return;
90102

103+
// If rounding is enabled, ensure we start from a rounded base before stepping
104+
if (round) {
105+
// If field is empty, treat as 0 (or min if provided) before stepping down
106+
if (inputRef.current.value === "") {
107+
inputRef.current.value = String(min ?? 0);
108+
} else {
109+
commitRoundedFromInput();
110+
}
111+
}
91112
inputRef.current.stepDown();
92-
93-
// Dispatch a native change event so the onChange handler is called
94113
const event = new Event("change", { bubbles: true });
95114
inputRef.current.dispatchEvent(event);
96115
};
97116

98-
const isMinDisabled = min !== undefined && value <= min;
99-
const isMaxDisabled = max !== undefined && value >= max;
117+
const numericValue = value === "" ? NaN : (value as number);
118+
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
119+
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
120+
121+
function clamp(val: number): number {
122+
if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0;
123+
let next = val;
124+
if (min !== undefined) next = Math.max(min, next);
125+
if (max !== undefined) next = Math.min(max, next);
126+
return next;
127+
}
128+
129+
function roundToStep(val: number): number {
130+
if (step <= 0) return val;
131+
// HTML number input uses min as the step base when provided, otherwise 0
132+
const base = min ?? 0;
133+
const shifted = val - base;
134+
const quotient = shifted / step;
135+
const floored = Math.floor(quotient);
136+
const ceiled = Math.ceil(quotient);
137+
const down = base + floored * step;
138+
const up = base + ceiled * step;
139+
const distDown = Math.abs(val - down);
140+
const distUp = Math.abs(up - val);
141+
// Ties go down
142+
return distUp < distDown ? up : down;
143+
}
144+
145+
function commitRoundedFromInput() {
146+
if (!inputRef.current || disabled || readOnly) return;
147+
const el = inputRef.current;
148+
const raw = el.value;
149+
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
150+
const numeric = Number(raw);
151+
if (Number.isNaN(numeric)) return; // ignore non-numeric
152+
const rounded = clamp(roundToStep(numeric));
153+
if (String(rounded) === String(value)) return;
154+
// Update the real input's value for immediate UI feedback
155+
el.value = String(rounded);
156+
// Invoke consumer onChange with the real element as target/currentTarget
157+
onChange({
158+
target: el,
159+
currentTarget: el,
160+
} as unknown as ChangeEvent<HTMLInputElement>);
161+
}
100162

101163
return (
102164
<div
@@ -113,7 +175,31 @@ function InputStepper({
113175
id={id}
114176
name={name}
115177
value={value}
116-
onChange={onChange}
178+
placeholder={placeholder}
179+
onChange={(e) => {
180+
// Allow empty string to pass through so user can clear the field
181+
if (e.currentTarget.value === "") {
182+
// reflect emptiness in the input and notify consumer as empty
183+
if (inputRef.current) inputRef.current.value = "";
184+
onChange({
185+
target: e.currentTarget,
186+
currentTarget: e.currentTarget,
187+
} as ChangeEvent<HTMLInputElement>);
188+
return;
189+
}
190+
onChange(e);
191+
}}
192+
onBlur={(e) => {
193+
// If blur is caused by clicking our step buttons, we prevent pointerdown
194+
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
195+
if (round) commitRoundedFromInput();
196+
}}
197+
onKeyDown={(e) => {
198+
if (e.key === "Enter" && round) {
199+
e.preventDefault();
200+
commitRoundedFromInput();
201+
}
202+
}}
117203
step={step}
118204
min={min}
119205
max={max}
@@ -131,6 +217,7 @@ function InputStepper({
131217
<button
132218
type="button"
133219
onClick={handleStepDown}
220+
onPointerDown={(e) => e.preventDefault()}
134221
disabled={disabled || isMinDisabled}
135222
aria-label={`Decrease by ${step}`}
136223
className={cn(
@@ -146,6 +233,7 @@ function InputStepper({
146233
<button
147234
type="button"
148235
onClick={handleStepUp}
236+
onPointerDown={(e) => e.preventDefault()}
149237
disabled={disabled || isMaxDisabled}
150238
aria-label={`Increase by ${step}`}
151239
className={cn(

0 commit comments

Comments
 (0)