11import { pick } from 'ramda' ;
2- import React , { KeyboardEvent , KeyboardEventHandler , useCallback , useEffect , useRef , useState } from 'react' ;
2+ import React , {
3+ KeyboardEvent ,
4+ KeyboardEventHandler ,
5+ useCallback ,
6+ useEffect ,
7+ useRef ,
8+ useState ,
9+ useId ,
10+ } from 'react' ;
311import fastIsNumeric from 'fast-isnumeric' ;
412import LoadingElement from '../utils/_LoadingElement' ;
5- import { styled } from 'styled-components' ;
6-
13+ import './css/input.css' ;
714
815const isNumeric = ( val : unknown ) : val is number => fastIsNumeric ( val ) ;
916const convert = ( val : unknown ) => ( isNumeric ( val ) ? + val : NaN ) ;
@@ -258,8 +265,6 @@ const inputProps: (keyof InputProps)[] = [
258265 'maxLength' ,
259266 'pattern' ,
260267 'size' ,
261- 'style' ,
262- 'id' ,
263268] ;
264269
265270const defaultProps : Partial < InputProps > = {
@@ -274,8 +279,6 @@ const defaultProps: Partial<InputProps> = {
274279 persistence_type : 'local' ,
275280} ;
276281
277- const StyledInput = styled . input `` ;
278-
279282/**
280283 * A basic HTML input control for entering text, numbers, or passwords.
281284 *
@@ -288,8 +291,9 @@ export default function Input(props: InputProps) {
288291 const input = useRef ( document . createElement ( 'input' ) ) ;
289292 const [ value , setValue ] = useState < InputProps [ 'value' ] > ( props . value ) ;
290293 const [ pendingEvent , setPendingEvent ] = useState < number > ( ) ;
294+ const inputId = useId ( ) ;
291295
292- const valprops = props . type === 'number' ? { } : { value} ;
296+ const valprops = props . type === 'number' ? { } : { value : value ?? '' } ;
293297 let { className} = props ;
294298 className = 'dash-input' + ( className ? ` ${ className } ` : '' ) ;
295299
@@ -307,13 +311,17 @@ export default function Input(props: InputProps) {
307311 ) ;
308312
309313 const onEvent = useCallback ( ( ) => {
310- const { value} = input . current ;
314+ const { value : inputValue } = input . current ;
311315 const { setProps} = props ;
312- const valueAsNumber = convert ( value ) ;
316+ const valueAsNumber = convert ( inputValue ) ;
313317 if ( props . type === 'number' ) {
314318 setPropValue ( props . value , valueAsNumber ?? value ) ;
315319 } else {
316- setProps ( { value} ) ;
320+ const propValue =
321+ inputValue === '' && props . value === undefined
322+ ? undefined
323+ : inputValue ;
324+ setProps ( { value : propValue } ) ;
317325 }
318326 setPendingEvent ( undefined ) ;
319327 } , [ props . setProps ] ) ;
@@ -374,9 +382,40 @@ export default function Input(props: InputProps) {
374382 [ pendingEvent ]
375383 ) ;
376384
385+ const handleStepperClick = useCallback (
386+ ( direction : 'increment' | 'decrement' ) => {
387+ const currentValue = parseFloat ( input . current . value ) || 0 ;
388+ const step = parseFloat ( props . step as string ) || 1 ;
389+ const newValue =
390+ direction === 'increment'
391+ ? currentValue + step
392+ : currentValue - step ;
393+
394+ // Apply min/max constraints
395+ let constrainedValue = newValue ;
396+ if ( props . min !== undefined ) {
397+ constrainedValue = Math . max (
398+ constrainedValue ,
399+ parseFloat ( props . min as string )
400+ ) ;
401+ }
402+ if ( props . max !== undefined ) {
403+ constrainedValue = Math . min (
404+ constrainedValue ,
405+ parseFloat ( props . max as string )
406+ ) ;
407+ }
408+
409+ input . current . value = constrainedValue . toString ( ) ;
410+ setValue ( constrainedValue . toString ( ) ) ;
411+ onEvent ( ) ;
412+ } ,
413+ [ props . step , props . min , props . max , onEvent ]
414+ ) ;
415+
377416 useEffect ( ( ) => {
378417 const { value} = input . current ;
379- if ( pendingEvent ) {
418+ if ( pendingEvent || props . value === value ) {
380419 return ;
381420 }
382421 const valueAsNumber = convert ( value ) ;
@@ -387,6 +426,11 @@ export default function Input(props: InputProps) {
387426 } , [ props . value , props . type , pendingEvent ] ) ;
388427
389428 useEffect ( ( ) => {
429+ // Skip this effect if the value change came from props update (not user input)
430+ if ( value === props . value ) {
431+ return ;
432+ }
433+
390434 const { debounce, type} = props ;
391435 const { selectionStart : cursorPosition } = input . current ;
392436 if ( debounce ) {
@@ -404,23 +448,67 @@ export default function Input(props: InputProps) {
404448 } else {
405449 onEvent ( ) ;
406450 }
407- } , [ value , props . debounce ] ) ;
451+ } , [ value , props . debounce , props . value ] ) ;
408452
409453 const pickedInputs = pick ( inputProps , props ) ;
410454
455+ const isNumberInput = props . type === 'number' ;
456+ const currentNumericValue = convert ( input . current . value || '0' ) ;
457+ const minValue = convert ( props . min ) ;
458+ const maxValue = convert ( props . max ) ;
459+ const disabled = [ true , 'disabled' , 'DISABLED' ] . includes (
460+ props . disabled ?? false
461+ ) ;
462+ const isDecrementDisabled = disabled || currentNumericValue <= minValue ;
463+ const isIncrementDisabled = disabled || currentNumericValue >= maxValue ;
464+
411465 return (
412466 < LoadingElement >
413- { ( loadingProps ) => (
414- < StyledInput
415- className = { className }
416- ref = { input }
417- onBlur = { onBlur }
418- onChange = { onChange }
419- onKeyPress = { onKeyPress }
420- { ...valprops }
421- { ...pickedInputs }
422- { ...loadingProps }
423- />
467+ { loadingProps => (
468+ < div
469+ id = { props . id }
470+ className = { `dash-input-container ${ className } ${
471+ props . type === 'hidden' ? ' dash-input-hidden' : ''
472+ } `. trim ( ) }
473+ style = { props . style }
474+ >
475+ < input
476+ id = { inputId }
477+ ref = { input }
478+ className = "dash-input-element"
479+ onBlur = { onBlur }
480+ onChange = { onChange }
481+ onKeyPress = { onKeyPress }
482+ { ...valprops }
483+ { ...pickedInputs }
484+ { ...loadingProps }
485+ disabled = { disabled }
486+ />
487+ { isNumberInput && (
488+ < button
489+ type = "button"
490+ className = "dash-input-stepper dash-stepper-decrement"
491+ onClick = { ( ) => handleStepperClick ( 'decrement' ) }
492+ disabled = { isDecrementDisabled }
493+ aria-controls = { inputId }
494+ aria-label = "Decrease value"
495+ >
496+ −
497+ </ button >
498+ ) }
499+ { isNumberInput && (
500+ < button
501+ type = "button"
502+ className = "dash-input-stepper dash-stepper-increment"
503+ onClick = { ( ) => handleStepperClick ( 'increment' ) }
504+ disabled = { isIncrementDisabled }
505+ aria-controls = { inputId }
506+ aria-label = "Increase value"
507+ >
508+ +
509+ </ button >
510+ ) }
511+ </ div >
424512 ) }
425513 </ LoadingElement >
426514 ) ;
0 commit comments