@@ -38,9 +38,38 @@ const styles = {
3838 } ,
3939} ;
4040
41+ /**
42+ * Delay before the value starts being changed again and again, when an
43+ * increment button is kept pressed (like the native buttons of number inputs do).
44+ */
45+ const INCREMENT_REPEAT_INITIAL_DELAY = 400 ;
46+ const INCREMENT_REPEAT_DELAY = 60 ;
47+
4148// $FlowFixMe[missing-local-annot]
4249const stopPropagation = e => e . stopPropagation ( ) ;
4350
51+ const ChevronArrowTopSvg = (
52+ < svg viewBox = "0 0 16 16" focusable = "false" aria-hidden = "true" >
53+ < path
54+ fillRule = "evenodd"
55+ clipRule = "evenodd"
56+ d = "M7.99998 6.66663C8.13902 6.66663 8.27177 6.72452 8.36638 6.8264L10.533 9.15973C10.7209 9.36209 10.7092 9.67845 10.5069 9.86636C10.3045 10.0543 9.98815 10.0425 9.80025 9.84018L7.99998 7.90143L6.19971 9.84018C6.01181 10.0425 5.69544 10.0543 5.49309 9.86636C5.29073 9.67845 5.27902 9.36209 5.46692 9.15973L7.63358 6.8264C7.72819 6.72452 7.86095 6.66663 7.99998 6.66663Z"
57+ fill = "currentColor"
58+ />
59+ </ svg >
60+ ) ;
61+
62+ const ChevronArrowBottomSvg = (
63+ < svg viewBox = "0 0 16 16" focusable = "false" aria-hidden = "true" >
64+ < path
65+ fillRule = "evenodd"
66+ clipRule = "evenodd"
67+ d = "M5.49309 6.80023C5.69544 6.61233 6.01181 6.62405 6.19971 6.8264L7.99998 8.76515L9.80025 6.8264C9.98815 6.62405 10.3045 6.61233 10.5069 6.80023C10.7092 6.98813 10.7209 7.3045 10.533 7.50685L8.36638 9.84019C8.27177 9.94207 8.13902 9.99996 7.99998 9.99996C7.86095 9.99996 7.72819 9.94207 7.63358 9.84019L5.46692 7.50685C5.27902 7.3045 5.29073 6.98813 5.49309 6.80023Z"
68+ fill = "currentColor"
69+ />
70+ </ svg >
71+ ) ;
72+
4473/**
4574 * A text field, inspired from Material UI, but lightweight
4675 * and faster to render (2 DOM elements, uncontrolled, pure CSS styling).
@@ -103,11 +132,76 @@ export const SimpleTextField: React.ComponentType<{
103132 getCaretPosition,
104133 } ) ) ;
105134
135+ const incrementRepeatTimeoutId = React . useRef < ?TimeoutID > ( null ) ;
136+ const incrementRepeatIntervalId = React . useRef < ?IntervalID > ( null ) ;
137+
138+ const stopIncrementing = React . useCallback ( ( ) => {
139+ if ( incrementRepeatTimeoutId . current ) {
140+ clearTimeout ( incrementRepeatTimeoutId . current ) ;
141+ incrementRepeatTimeoutId . current = null ;
142+ }
143+ if ( incrementRepeatIntervalId . current ) {
144+ clearInterval ( incrementRepeatIntervalId . current ) ;
145+ incrementRepeatIntervalId . current = null ;
146+ }
147+ } , [ ] ) ;
148+
149+ // Ensure the value is not changed forever if the component is unmounted
150+ // while an increment button is pressed.
151+ React . useEffect ( ( ) => stopIncrementing , [ stopIncrementing ] ) ;
152+
153+ const { onChange, additionalContext, disabled } = props ;
154+ const incrementValue = React . useCallback (
155+ ( direction : 'up' | 'down' ) => {
156+ const input = inputRef . current ;
157+ if ( ! input || disabled ) return ;
158+
159+ // Let the browser do the stepping, so that the value stays valid.
160+ if ( direction === 'up' ) input . stepUp ( ) ;
161+ else input . stepDown ( ) ;
162+ onChange ( input . value , additionalContext , 'change' ) ;
163+ } ,
164+ [ onChange , additionalContext , disabled ]
165+ ) ;
166+
167+ const startIncrementing = React . useCallback (
168+ (
169+ event : SyntheticPointerEvent < HTMLButtonElement > ,
170+ direction : 'up' | 'down'
171+ ) => {
172+ // Only the main button of the pointer changes the value.
173+ if ( event . button !== 0 ) return ;
174+
175+ // Keep the focus on the input (and give it to it if it was elsewhere),
176+ // like the native increment buttons do.
177+ event . preventDefault ( ) ;
178+ if ( inputRef . current ) inputRef . current . focus ( ) ;
179+
180+ stopIncrementing ( ) ;
181+ incrementValue ( direction ) ;
182+
183+ // Then, keep changing the value while the button is pressed.
184+ incrementRepeatTimeoutId . current = setTimeout ( ( ) => {
185+ incrementRepeatIntervalId . current = setInterval (
186+ ( ) => incrementValue ( direction ) ,
187+ INCREMENT_REPEAT_DELAY
188+ ) ;
189+ } , INCREMENT_REPEAT_INITIAL_DELAY ) ;
190+ } ,
191+ [ incrementValue , stopIncrementing ]
192+ ) ;
193+
194+ // The native increment buttons of number inputs can't be styled (and are
195+ // particularly unpleasant to look at on Firefox): they are hidden by the
196+ // CSS and replaced by these ones.
197+ const hasIncrementButtons = props . type === 'number' && ! props . disabled ;
198+
106199 return (
107200 < div
108201 className = { classNames ( {
109202 [ classes . simpleTextField ] : true ,
110203 [ classes . disabled ] : props . disabled ,
204+ [ classes . withIncrementButtons ] : hasIncrementButtons ,
111205 } ) }
112206 >
113207 < input
@@ -148,6 +242,36 @@ export const SimpleTextField: React.ComponentType<{
148242 } }
149243 style = { props . italic ? styles . italic : undefined }
150244 />
245+ { hasIncrementButtons && (
246+ < div className = { classes . incrementButtons } >
247+ < button
248+ type = "button"
249+ // Not focusable: the value can be changed with the arrow keys
250+ // when the input is focused.
251+ tabIndex = { - 1 }
252+ aria-hidden = "true"
253+ className = { classes . incrementButton }
254+ onPointerDown = { event => startIncrementing ( event , 'up' ) }
255+ onPointerUp = { stopIncrementing }
256+ onPointerLeave = { stopIncrementing }
257+ onPointerCancel = { stopIncrementing }
258+ >
259+ { ChevronArrowTopSvg }
260+ </ button >
261+ < button
262+ type = "button"
263+ tabIndex = { - 1 }
264+ aria-hidden = "true"
265+ className = { classes . incrementButton }
266+ onPointerDown = { event => startIncrementing ( event , 'down' ) }
267+ onPointerUp = { stopIncrementing }
268+ onPointerLeave = { stopIncrementing }
269+ onPointerCancel = { stopIncrementing }
270+ >
271+ { ChevronArrowBottomSvg }
272+ </ button >
273+ </ div >
274+ ) }
151275 </ div >
152276 ) ;
153277 }
0 commit comments