@@ -4,9 +4,9 @@ import { Header2 } from "~/components/primitives/Headers";
44import { cn } from "~/utils/cn" ;
55
66export 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
5152interface 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
6467function 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