Skip to content

Commit fa4e9ca

Browse files
authored
Various code improvements and added keyboard shortcuts (#10)
* various code improvements and added keyboard shortcuts * update tests * fix time input field * fix time input better
1 parent 4630942 commit fa4e9ca

7 files changed

Lines changed: 236 additions & 84 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
"type": "module",
2828
"jest": {
2929
"testEnvironment": "jsdom",
30+
"setupFilesAfterEnv": [
31+
"<rootDir>/src/setupTests.js"
32+
],
3033
"moduleNameMapper": {
3134
"^.+\\.svg$": "jest-svg-transformer",
3235
"^.+\\.(css|less|scss)$": "identity-obj-proxy"

src/App.jsx

Lines changed: 96 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+
import React, { useCallback, useEffect, useState } from 'react';
22
import classNames from 'classnames';
33
import './styles/App.css';
44
import { DEFAULT_LIMIT_SEC, DEFAULT_WARNING_SEC } from './constants';
@@ -12,15 +12,30 @@ const App = () => {
1212
const [timeWarning, setTimeWarning] = useState(DEFAULT_WARNING_SEC);
1313

1414
useEffect(() => {
15-
if (timerStarted) {
16-
const timeout = setTimeout(() => {
17-
setTime(timer(timeElapsed));
18-
}, 1000);
19-
return () => clearTimeout(timeout);
20-
} else {
21-
setTime(timeElapsed);
22-
}
23-
}, [setTime, timeElapsed, timerStarted]);
15+
if (!timerStarted) return;
16+
17+
const timeout = setTimeout(() => {
18+
setTime(timer(timeElapsed));
19+
}, 1000);
20+
21+
return () => clearTimeout(timeout);
22+
}, [timeElapsed, timerStarted]);
23+
24+
// Handle keyboard shortcuts
25+
useEffect(() => {
26+
const handleKeyPress = (event) => {
27+
if (event.code === 'Space') {
28+
event.preventDefault();
29+
setTimerStatus((prev) => !prev);
30+
} else if (event.code === 'KeyR') {
31+
event.preventDefault();
32+
setTime(0);
33+
}
34+
};
35+
36+
window.addEventListener('keydown', handleKeyPress);
37+
return () => window.removeEventListener('keydown', handleKeyPress);
38+
}, []);
2439

2540
const handleLimitUpdate = useCallback((value) => {
2641
setTimeLimit(value);
@@ -30,73 +45,97 @@ const App = () => {
3045
setTimeWarning(value);
3146
}, []);
3247

33-
const renderControls = useMemo(() => {
34-
return (
35-
<div>
36-
<button
37-
type="button"
38-
className={classNames('btn', {
39-
'btn-primary': !timerStarted,
40-
'btn-danger': timerStarted,
41-
})}
42-
onClick={() => setTimerStatus(!timerStarted)}
43-
>
44-
{timerStarted ? 'Stop' : 'Start'}
45-
</button>
46-
<button
47-
type="button"
48-
className="btn btn-dark"
49-
onClick={() => setTime(0)}
50-
>
51-
Reset
52-
</button>
48+
const handleReset = useCallback(() => {
49+
setTime(0);
50+
}, []);
51+
52+
const handleToggleTimer = useCallback(() => {
53+
setTimerStatus((prev) => !prev);
54+
}, []);
55+
56+
const isWarning = timeElapsed > timeLimit - timeWarning;
57+
const isOverLimit = timeElapsed > timeLimit;
58+
59+
return (
60+
<div className="position-relative">
61+
<div
62+
className={classNames('timer', {
63+
warning: isWarning && !isOverLimit,
64+
stop: isOverLimit,
65+
})}
66+
>
67+
<div className="time" role="timer" aria-live="polite">
68+
{formatTime(timeElapsed)}
69+
</div>
70+
71+
<div>
72+
<button
73+
type="button"
74+
className={classNames('btn', {
75+
'btn-primary': !timerStarted,
76+
'btn-danger': timerStarted,
77+
})}
78+
onClick={handleToggleTimer}
79+
aria-label={timerStarted ? 'Stop timer' : 'Start timer'}
80+
>
81+
{timerStarted ? 'Stop' : 'Start'}
82+
</button>
83+
<button
84+
type="button"
85+
className="btn btn-dark"
86+
onClick={handleReset}
87+
aria-label="Reset timer to zero"
88+
>
89+
Reset
90+
</button>
91+
</div>
5392
</div>
54-
);
55-
}, [timerStarted]);
5693

57-
const renderSettings = useMemo(() => {
58-
return (
5994
<div className="container my-5">
6095
<div className="row">
6196
<div className="col">
6297
<div className="input-group">
63-
<label className="input-group-text">Limit</label>
98+
<label className="input-group-text" htmlFor="time-limit-input">
99+
Limit
100+
</label>
64101
<TimeInput
65-
ariaLabel="Set Time Limit"
66-
ariaDescribedby="time-limit"
102+
id="time-limit-input"
103+
ariaLabel="Set time limit in minutes and seconds"
104+
ariaDescribedby="time-limit-help"
67105
placeholderSec={timeLimit}
68106
onChange={handleLimitUpdate}
69-
></TimeInput>
107+
/>
70108
</div>
109+
<small id="time-limit-help" className="form-text text-muted">
110+
Timer stops when limit is reached
111+
</small>
71112
</div>
72113
<div className="col">
73114
<div className="input-group">
74115
<TimeInput
75-
ariaLabel="Set Time Limit"
76-
ariaDescribedby="time-limit"
116+
id="time-warning-input"
117+
ariaLabel="Set warning time in minutes and seconds"
118+
ariaDescribedby="time-warning-help"
77119
placeholderSec={timeWarning}
78120
onChange={handleWarningUpdate}
79-
></TimeInput>
80-
<label className="input-group-text">Warning</label>
121+
/>
122+
<label className="input-group-text" htmlFor="time-warning-input">
123+
Warning
124+
</label>
81125
</div>
126+
<small id="time-warning-help" className="form-text text-muted">
127+
Warning shown before time limit
128+
</small>
129+
</div>
130+
</div>
131+
<div className="row mt-3">
132+
<div className="col text-center">
133+
<small className="text-muted">
134+
Keyboard shortcuts: Space to start/stop, R to reset
135+
</small>
82136
</div>
83137
</div>
84138
</div>
85-
);
86-
}, [handleLimitUpdate, handleWarningUpdate, timeLimit, timeWarning]);
87-
88-
return (
89-
<div className="position-relative">
90-
<div
91-
className={classNames('timer', {
92-
warning: timeElapsed > timeLimit - timeWarning,
93-
stop: timeElapsed > timeLimit,
94-
})}
95-
>
96-
<div className="time">{formatTime(timeElapsed)}</div>
97-
{renderControls}
98-
</div>
99-
{renderSettings}
100139
</div>
101140
);
102141
};

src/components/time-input.jsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,26 @@ export const TimeInput = ({
1212
onChange,
1313
}) => {
1414
const [value, setValue] = useState(formatTime(placeholderSec));
15-
const handleChange = useCallback(
16-
({ target }) => {
17-
setValue(formatInputTime(formatTime(target.value)));
18-
onChange(convertToSeconds(target.value));
19-
},
20-
[onChange]
21-
);
15+
const [isEditing, setIsEditing] = useState(false);
16+
17+
const handleChange = useCallback(({ target }) => {
18+
// While editing, just store the raw value (digits only)
19+
setValue(target.value);
20+
}, []);
21+
22+
const handleFocus = useCallback(() => {
23+
setIsEditing(true);
24+
// Clear the formatted value to let user type fresh
25+
setValue('');
26+
}, []);
27+
28+
const handleBlur = useCallback(() => {
29+
setIsEditing(false);
30+
// Format the value when done editing
31+
const formattedValue = formatInputTime(value);
32+
setValue(formattedValue);
33+
onChange(convertToSeconds(formattedValue));
34+
}, [value, onChange]);
2235

2336
return (
2437
<input
@@ -29,6 +42,8 @@ export const TimeInput = ({
2942
aria-describedby={ariaDescribedby}
3043
placeholder={formatTime(placeholderSec)}
3144
onChange={handleChange}
45+
onFocus={handleFocus}
46+
onBlur={handleBlur}
3247
value={value}
3348
></input>
3449
);

src/components/time-input.test.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ const setup = () => {
77
<TimeInput
88
ariaLabel="time"
99
ariaDescribedby="time-limit"
10-
placeholder="0:30"
10+
placeholder="2:30"
1111
onChange={(event) => console.log('change', event)}
1212
value={90}
13-
></TimeInput>
13+
></TimeInput>,
1414
);
1515
const input = screen.getByLabelText('time');
1616
return {
@@ -21,18 +21,25 @@ const setup = () => {
2121

2222
test('Input should set display value', () => {
2323
const { input } = setup();
24+
25+
// Focus, type, then blur to trigger formatting
26+
fireEvent.focus(input);
2427
fireEvent.change(input, { target: { value: '23' } });
28+
fireEvent.blur(input);
2529
expect(input.value).toBe('0:23');
2630

31+
fireEvent.focus(input);
2732
fireEvent.change(input, { target: { value: 'y15efg' } });
33+
fireEvent.blur(input);
2834
expect(input.value).toBe('0:15');
2935

30-
fireEvent.change(input, { target: { value: '8' } });
31-
fireEvent.change(input, { target: { value: '80' } });
36+
fireEvent.focus(input);
3237
fireEvent.change(input, { target: { value: '800' } });
38+
fireEvent.blur(input);
3339
expect(input.value).toBe('8:00');
3440

35-
fireEvent.change(input, { target: { value: '9' } });
41+
fireEvent.focus(input);
3642
fireEvent.change(input, { target: { value: '90' } });
43+
fireEvent.blur(input);
3744
expect(input.value).toBe('1:30');
3845
});

0 commit comments

Comments
 (0)