1
1
import { 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' ;
3
11
import fastIsNumeric from 'fast-isnumeric' ;
4
12
import LoadingElement from '../utils/_LoadingElement' ;
5
- import { styled } from 'styled-components' ;
6
-
13
+ import './css/input.css' ;
7
14
8
15
const isNumeric = ( val : unknown ) : val is number => fastIsNumeric ( val ) ;
9
16
const convert = ( val : unknown ) => ( isNumeric ( val ) ? + val : NaN ) ;
@@ -258,8 +265,6 @@ const inputProps: (keyof InputProps)[] = [
258
265
'maxLength' ,
259
266
'pattern' ,
260
267
'size' ,
261
- 'style' ,
262
- 'id' ,
263
268
] ;
264
269
265
270
const defaultProps : Partial < InputProps > = {
@@ -274,8 +279,6 @@ const defaultProps: Partial<InputProps> = {
274
279
persistence_type : 'local' ,
275
280
} ;
276
281
277
- const StyledInput = styled . input `` ;
278
-
279
282
/**
280
283
* A basic HTML input control for entering text, numbers, or passwords.
281
284
*
@@ -288,8 +291,9 @@ export default function Input(props: InputProps) {
288
291
const input = useRef ( document . createElement ( 'input' ) ) ;
289
292
const [ value , setValue ] = useState < InputProps [ 'value' ] > ( props . value ) ;
290
293
const [ pendingEvent , setPendingEvent ] = useState < number > ( ) ;
294
+ const inputId = useId ( ) ;
291
295
292
- const valprops = props . type === 'number' ? { } : { value} ;
296
+ const valprops = props . type === 'number' ? { } : { value : value ?? '' } ;
293
297
let { className} = props ;
294
298
className = 'dash-input' + ( className ? ` ${ className } ` : '' ) ;
295
299
@@ -307,13 +311,17 @@ export default function Input(props: InputProps) {
307
311
) ;
308
312
309
313
const onEvent = useCallback ( ( ) => {
310
- const { value} = input . current ;
314
+ const { value : inputValue } = input . current ;
311
315
const { setProps} = props ;
312
- const valueAsNumber = convert ( value ) ;
316
+ const valueAsNumber = convert ( inputValue ) ;
313
317
if ( props . type === 'number' ) {
314
318
setPropValue ( props . value , valueAsNumber ?? value ) ;
315
319
} else {
316
- setProps ( { value} ) ;
320
+ const propValue =
321
+ inputValue === '' && props . value === undefined
322
+ ? undefined
323
+ : inputValue ;
324
+ setProps ( { value : propValue } ) ;
317
325
}
318
326
setPendingEvent ( undefined ) ;
319
327
} , [ props . setProps ] ) ;
@@ -374,9 +382,40 @@ export default function Input(props: InputProps) {
374
382
[ pendingEvent ]
375
383
) ;
376
384
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
+
377
416
useEffect ( ( ) => {
378
417
const { value} = input . current ;
379
- if ( pendingEvent ) {
418
+ if ( pendingEvent || props . value === value ) {
380
419
return ;
381
420
}
382
421
const valueAsNumber = convert ( value ) ;
@@ -387,6 +426,11 @@ export default function Input(props: InputProps) {
387
426
} , [ props . value , props . type , pendingEvent ] ) ;
388
427
389
428
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
+
390
434
const { debounce, type} = props ;
391
435
const { selectionStart : cursorPosition } = input . current ;
392
436
if ( debounce ) {
@@ -404,23 +448,67 @@ export default function Input(props: InputProps) {
404
448
} else {
405
449
onEvent ( ) ;
406
450
}
407
- } , [ value , props . debounce ] ) ;
451
+ } , [ value , props . debounce , props . value ] ) ;
408
452
409
453
const pickedInputs = pick ( inputProps , props ) ;
410
454
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
+
411
465
return (
412
466
< 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 >
424
512
) }
425
513
</ LoadingElement >
426
514
) ;
0 commit comments