Refactor DurationInput to be more predictable

Using two inputs instead of one with finnicky magic for a better user experiences
This commit is contained in:
Justin Edmund 2023-02-02 02:30:49 -08:00
parent 81abb93ed6
commit 7674e5d792
2 changed files with 134 additions and 48 deletions

View file

@ -0,0 +1,35 @@
.Duration {
align-items: center;
background: var(--input-bound-bg);
border: 2px solid transparent;
border-radius: $input-corner;
display: flex;
padding: 0 calc($unit-2x - 2px);
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus-within {
border: 2px solid $blue;
outline: none;
}
.Input {
background: transparent;
border: none;
padding: 0;
width: initial;
height: 100%;
padding: calc($unit-2x - 2px) 0;
&:hover {
background: transparent;
}
&:focus,
&:focus-visible {
border: none;
}
}
}

View file

@ -1,8 +1,7 @@
import React, { useState, ChangeEvent, KeyboardEvent, useEffect } from 'react' import React, { useState, ChangeEvent, KeyboardEvent } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import Input from '~components/Input' import Input from '~components/Input'
import './index.scss' import './index.scss'
interface Props interface Props
@ -15,50 +14,57 @@ interface Props
} }
const DurationInput = React.forwardRef<HTMLInputElement, Props>( const DurationInput = React.forwardRef<HTMLInputElement, Props>(
function DurationInput( function DurationInput({ className, value, onValueChange }, forwardedRef) {
{ className, placeholder, value, onValueChange }, // State
forwardedRef
) {
const [duration, setDuration] = useState('') const [duration, setDuration] = useState('')
const [minutesSelected, setMinutesSelected] = useState(false)
const [secondsSelected, setSecondsSelected] = useState(false)
useEffect(() => { // Refs
if (value > 0) setDuration(convertSecondsToString(value)) const minutesRef = React.createRef<HTMLInputElement>()
}, [value]) const secondsRef = React.createRef<HTMLInputElement>()
function convertStringToSeconds(string: string) { // Event handlers: On value change
const parts = string.split(':') function handleMinutesChange(event: ChangeEvent<HTMLInputElement>) {
const minutes = parseInt(parts[0]) const minutes = parseInt(event.currentTarget.value)
const seconds = parseInt(parts[1]) const seconds = secondsRef.current
? parseInt(secondsRef.current.value)
: 0
return minutes * 60 + seconds handleChange(minutes, seconds)
} }
function convertSecondsToString(value: number) { function handleSecondsChange(event: ChangeEvent<HTMLInputElement>) {
const minutes = Math.floor(value / 60) const seconds = parseInt(event.currentTarget.value)
const seconds = value - minutes * 60 const minutes = minutesRef.current
? parseInt(minutesRef.current.value)
: 0
const paddedMinutes = padNumber(`${minutes}`, '0', 2) handleChange(minutes, seconds)
return `${paddedMinutes}:${seconds}`
} }
function padNumber(string: string, pad: string, length: number) { function handleChange(minutes: number, seconds: number) {
return (new Array(length + 1).join(pad) + string).slice(-length) onValueChange(minutes * 60 + seconds)
} }
function handleChange(event: ChangeEvent<HTMLInputElement>) { // Event handler: Key presses
const value = event.currentTarget.value function handleKeyUp(event: KeyboardEvent<HTMLInputElement>) {
const durationInSeconds = convertStringToSeconds(value) const input = event.currentTarget
onValueChange(durationInSeconds)
if (input.selectionStart === 0 && input.selectionEnd === 2) {
if (input === minutesRef.current) {
setMinutesSelected(true)
} else if (input === secondsRef.current) {
setSecondsSelected(true)
}
}
} }
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) { function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
// Allow the key to be processed normally
return return
} }
// Get the current value
const input = event.currentTarget const input = event.currentTarget
let value = event.currentTarget.value let value = event.currentTarget.value
@ -95,16 +101,28 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
const isNumber = !isNaN(char) const isNumber = !isNaN(char)
// Check if the character should be accepted or rejected // Check if the character should be accepted or rejected
if (!isNumber || value.length >= 5) { if (!isNumber || value.length >= 2) {
// Reject the character // Reject the character if the user doesn't have the entire string selected
event.preventDefault() if (!minutesSelected && input === minutesRef.current)
} else if (value.length === 2) { event.preventDefault()
// Insert a colon after the second digit else if (
input.value = value + ':' !secondsSelected &&
input === secondsRef.current &&
getSeconds() > 9
)
event.preventDefault()
else {
setDuration(value)
setMinutesSelected(false)
setSecondsSelected(false)
}
} else {
setDuration(value)
} }
} }
} }
// Methods: Time manipulation
function incrementTime(time: string): string { function incrementTime(time: string): string {
// Split the time into minutes and seconds // Split the time into minutes and seconds
let [minutes, seconds] = time.split(':').map(Number) let [minutes, seconds] = time.split(':').map(Number)
@ -144,21 +162,54 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
return `${minutes}:${seconds}` return `${minutes}:${seconds}`
} }
// Methods: Miscellaneous
function getMinutes() {
const minutes = Math.floor(value / 60)
return minutes
}
function getSeconds() {
const seconds = value % 60
return seconds
}
return ( return (
<Input <div className={classNames(className, { Duration: true })}>
type="text" <Input
className={classNames( ref={minutesRef}
{ type="text"
Duration: true, className={classNames(
AlignRight: true, {
}, AlignRight: true,
className },
)} className
value={duration} )}
onChange={handleChange} value={getMinutes()}
onKeyDown={handleKeyDown} onChange={handleMinutesChange}
placeholder={placeholder} onKeyUp={handleKeyUp}
/> onKeyDown={handleKeyDown}
placeholder="mm"
size={3}
/>
<span>:</span>
<Input
ref={secondsRef}
type="text"
className={classNames(
{
AlignRight: true,
},
className
)}
value={`${getSeconds()}`.padStart(2, '0')}
onChange={handleSecondsChange}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
placeholder="ss"
size={2}
/>
</div>
) )
} }
) )