11import { clsx } from "clsx" ;
2- import { forwardRef } from "react" ;
2+ import { forwardRef , useState , useCallback , useEffect , useRef } from "react" ;
33import { Minus , Plus } from "@phosphor-icons/react" ;
44
55export interface NumberStepperProps {
@@ -23,7 +23,7 @@ const NumberStepper = forwardRef<HTMLDivElement, NumberStepperProps>(
2323 value,
2424 onChange,
2525 min = 0 ,
26- max = 100 ,
26+ max,
2727 step = 1 ,
2828 allowFloat = false ,
2929 disabled = false ,
@@ -35,21 +35,50 @@ const NumberStepper = forwardRef<HTMLDivElement, NumberStepperProps>(
3535 } ,
3636 ref ,
3737 ) => {
38+ const [ editing , setEditing ] = useState ( false ) ;
39+ const [ inputValue , setInputValue ] = useState ( "" ) ;
40+ const inputRef = useRef < HTMLInputElement > ( null ) ;
41+
42+ useEffect ( ( ) => {
43+ if ( editing && inputRef . current ) {
44+ inputRef . current . focus ( ) ;
45+ inputRef . current . select ( ) ;
46+ }
47+ } , [ editing ] ) ;
48+
49+ const clampValue = useCallback (
50+ ( v : number ) => {
51+ let clamped = Math . max ( min , v ) ;
52+ if ( max !== undefined ) clamped = Math . min ( max , clamped ) ;
53+ return clamped ;
54+ } ,
55+ [ min , max ] ,
56+ ) ;
57+
3858 const handleDecrement = ( ) => {
3959 const newValue = allowFloat
40- ? Math . max ( min , value - step )
41- : Math . max ( min , Math . floor ( value - step ) ) ;
60+ ? clampValue ( value - step )
61+ : clampValue ( Math . floor ( value - step ) ) ;
4262 onChange ( newValue ) ;
4363 } ;
4464
4565 const handleIncrement = ( ) => {
4666 const newValue = allowFloat
47- ? Math . min ( max , value + step )
48- : Math . min ( max , Math . ceil ( value + step ) ) ;
67+ ? clampValue ( value + step )
68+ : clampValue ( Math . ceil ( value + step ) ) ;
4969 onChange ( newValue ) ;
5070 } ;
5171
52- const progress = ( ( value - min ) / ( max - min ) ) * 100 ;
72+ const commitInput = ( ) => {
73+ const parsed = allowFloat ? parseFloat ( inputValue ) : parseInt ( inputValue , 10 ) ;
74+ if ( ! isNaN ( parsed ) ) {
75+ onChange ( clampValue ( parsed ) ) ;
76+ }
77+ setEditing ( false ) ;
78+ } ;
79+
80+ const progress =
81+ max !== undefined ? ( ( value - min ) / ( max - min ) ) * 100 : undefined ;
5382
5483 return (
5584 < div ref = { ref } className = { clsx ( "flex flex-col gap-1" , className ) } >
@@ -72,13 +101,37 @@ const NumberStepper = forwardRef<HTMLDivElement, NumberStepperProps>(
72101 >
73102 < Minus className = "size-4 text-ink" />
74103 </ button >
75- < span className = "min-w-[3rem] text-center text-sm font-medium text-ink" >
76- { allowFloat ? value . toFixed ( 1 ) : value } { suffix }
77- </ span >
104+ { editing ? (
105+ < input
106+ ref = { inputRef }
107+ type = "text"
108+ inputMode = "decimal"
109+ value = { inputValue }
110+ onChange = { ( e ) => setInputValue ( e . target . value ) }
111+ onBlur = { commitInput }
112+ onKeyDown = { ( e ) => {
113+ if ( e . key === "Enter" ) commitInput ( ) ;
114+ if ( e . key === "Escape" ) setEditing ( false ) ;
115+ } }
116+ className = "min-w-[3rem] w-[4rem] bg-transparent text-center text-sm font-medium text-ink outline-none"
117+ />
118+ ) : (
119+ < span
120+ className = "min-w-[3rem] text-center text-sm font-medium text-ink cursor-text select-none"
121+ onDoubleClick = { ( ) => {
122+ if ( ! disabled ) {
123+ setInputValue ( String ( allowFloat ? value . toFixed ( 1 ) : value ) ) ;
124+ setEditing ( true ) ;
125+ }
126+ } }
127+ >
128+ { allowFloat ? value . toFixed ( 1 ) : value } { suffix }
129+ </ span >
130+ ) }
78131 < button
79132 type = "button"
80133 onClick = { handleIncrement }
81- disabled = { disabled || value >= max }
134+ disabled = { disabled || ( max !== undefined && value >= max ) }
82135 className = { clsx (
83136 "flex h-8 w-8 items-center justify-center rounded-md border border-app-line bg-app-box" ,
84137 "hover:bg-app-hover disabled:opacity-50 disabled:cursor-not-allowed" ,
@@ -88,7 +141,7 @@ const NumberStepper = forwardRef<HTMLDivElement, NumberStepperProps>(
88141 < Plus className = "size-4 text-ink" />
89142 </ button >
90143 </ div >
91- { showProgress && (
144+ { showProgress && progress !== undefined && (
92145 < div className = "h-1 w-full overflow-hidden rounded-full bg-app-line" >
93146 < div
94147 className = "h-full bg-accent transition-all duration-200"
0 commit comments