Skip to content

Commit d139047

Browse files
committed
BL-15962 Fix empty input and backspace handling in SmallNumberPicker
1 parent 7fc00c3 commit d139047

6 files changed

Lines changed: 82 additions & 34 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The front-end uses yarn 1.22.22. Never ever use npm.
88
- do not define a props data type unless it is huge
99
- example: export const SomeComponent: React.FunctionComponent<{initiallySelectedGroupIndex: number;> = (props) => {...}
1010

11-
- Avoid removing existing comments.
11+
- Avoid removing existing comments unless your changes make them inaccurate/obsolete
1212
- Avoid adding a comment like "// add this line".
1313

1414
- Style elements using the css macro from @emotion/react directly on the element being styled, using the css prop. E.g. `<div css={css`color:red`}>`

src/BloomBrowserUI/pageChooser/selectedTemplatePageControls.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export const SelectedTemplatePageControls: React.FunctionComponent<
5757
const minimumPagesToAdd = 1;
5858
const maximumPagesToAdd = 99;
5959
const [numberToAdd, setNumberToAdd] = useState<number>(minimumPagesToAdd);
60+
// Number picker snaps back to a valid value on blur if the user clicks away from the input with an invalid value, so isNumberToAddValid will usually be true
61+
const [isNumberToAddValid, setIsNumberToAddValid] = useState(true);
6062

6163
const captionKey = "TemplateBooks.PageLabel." + props.caption;
6264
const descriptionKey = "TemplateBooks.PageDescription." + props.caption;
@@ -73,7 +75,10 @@ export const SelectedTemplatePageControls: React.FunctionComponent<
7375
};
7476

7577
const isAddOrChoosePageButtonEnabled = (): boolean => {
76-
return !props.forChangeLayout || !props.willLoseData || continueChecked;
78+
const canProceedWithLayoutChoice =
79+
!props.forChangeLayout || !props.willLoseData || continueChecked;
80+
const hasValidAddCount = !!props.forChangeLayout || isNumberToAddValid;
81+
return canProceedWithLayoutChoice && hasValidAddCount;
7782
};
7883

7984
const numberOfPagesTooltip = useL10n(
@@ -254,11 +259,6 @@ export const SelectedTemplatePageControls: React.FunctionComponent<
254259
width: 200px;
255260
font-weight: bold !important;
256261
font-size: 10pt !important;
257-
258-
&:disabled {
259-
color: ${kBloomBuff};
260-
border: medium solid ${kBloomBuff};
261-
}
262262
`}
263263
l10nKey={buttonKey}
264264
hasText={true}
@@ -295,6 +295,7 @@ export const SelectedTemplatePageControls: React.FunctionComponent<
295295
minLimit={minimumPagesToAdd}
296296
maxLimit={maximumPagesToAdd}
297297
handleChange={setNumberToAdd}
298+
onValidityChange={setIsNumberToAddValid}
298299
tooltip={numberOfPagesTooltip}
299300
/>
300301
</div>

src/BloomBrowserUI/react_components/numberChooserDialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const NumberChooserDialog: React.FunctionComponent<
3333
useSetupBloomDialog(props.dialogEnvironment);
3434

3535
const [numberChosen, setNumberChosen] = useState(props.min);
36+
// The number picker snaps back to a valid value on blur if the user clicks away from the input with an invalid value, so isNumberPickerValid will usually be true
37+
const [isNumberPickerValid, setIsNumberPickerValid] = useState(true);
3638

3739
return (
3840
<BloomDialog {...propsForBloomDialog}>
@@ -56,6 +58,7 @@ export const NumberChooserDialog: React.FunctionComponent<
5658
minLimit={props.min}
5759
maxLimit={props.max}
5860
handleChange={setNumberChosen}
61+
onValidityChange={setIsNumberPickerValid}
5962
/>
6063
</div>
6164
</div>
@@ -64,7 +67,7 @@ export const NumberChooserDialog: React.FunctionComponent<
6467
<BloomButton
6568
l10nKey="Common.OK"
6669
hasText={true}
67-
enabled={true}
70+
enabled={isNumberPickerValid}
6871
variant={"contained"}
6972
onClick={() => {
7073
props.onClick(numberChosen);

src/BloomBrowserUI/react_components/smallNumberPicker.less

Lines changed: 0 additions & 10 deletions
This file was deleted.

src/BloomBrowserUI/react_components/smallNumberPicker.tsx

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,98 @@
1+
import { css } from "@emotion/react";
12
import * as React from "react";
23
import { useState } from "react";
34
import TextField from "@mui/material/TextField";
45
import ReactToolTip from "react-tooltip";
5-
import "./smallNumberPicker.less";
66

77
export interface INumberChooserProps {
88
maxLimit: number; // a valid result cannot be greater than this
99
minLimit?: number; // a valid result cannot be less than this
1010
handleChange: (newNumber: number) => void;
11+
onValidityChange?: (isValid: boolean) => void; // Notifies parent about validity changes
1112
tooltip?: string; // caller should localize
1213
}
1314

15+
/**
16+
* A React component for selecting nonnegative integers within a specified range.
17+
* Ensures that the input adheres to the constraints and provides immediate feedback for invalid input.
18+
*/
1419
export const SmallNumberPicker: React.FunctionComponent<INumberChooserProps> = (
1520
props: INumberChooserProps,
1621
) => {
17-
const initialValue = props.minLimit === undefined ? 1 : props.minLimit;
18-
const [chosenNumber, setChosenNumber] = useState(initialValue);
22+
const minimumValue = props.minLimit ?? 0;
23+
const initialValue = minimumValue;
24+
const [displayValue, setDisplayValue] = useState(initialValue.toString());
25+
const [lastValidValue, setLastValidValue] = useState(initialValue);
26+
27+
// We have the input allow empty string so that the user can clear the input before entering a new number
28+
// but don't persist or submit it
29+
function isValid(input: HTMLInputElement): boolean {
30+
return input.validity.valid && input.value !== "";
31+
}
1932

2033
const handleNumberChange = (event: React.ChangeEvent<HTMLInputElement>) => {
2134
const newString = event.target.value;
22-
const newNum = parseInt(newString);
35+
const newNum = event.target.valueAsNumber;
36+
37+
// Don't allow typing in invalid characters; immediately snap back
38+
// Except we don't prevent underflow immediately, so e.g. users can type digits "10" when the minimum is 2.
39+
// Number inputs allow e for exponential notation but for a small number picker it only makes behavior more confusing
2340
if (
24-
!newNum ||
25-
newNum > props.maxLimit ||
26-
(props.minLimit && newNum < props.minLimit)
41+
event.target.validity.badInput ||
42+
event.target.validity.rangeOverflow ||
43+
newString.toLowerCase().includes("e")
2744
) {
28-
setChosenNumber(initialValue);
29-
props.handleChange(initialValue);
30-
} else {
31-
setChosenNumber(newNum);
45+
return;
46+
}
47+
48+
setDisplayValue(newString);
49+
50+
const valid = isValid(event.target);
51+
props.onValidityChange?.(valid);
52+
53+
if (valid) {
54+
setLastValidValue(newNum);
3255
props.handleChange(newNum);
3356
}
3457
};
3558

36-
// We would love to set the TextField "type" to "number", but this introduces up/down arrows that we
37-
// can't get rid of in Firefox and have the input still perform as a number input. This means we have to
38-
// use a "text" style input and handle max and min and letter input in code. Any invalid input sets the
39-
// input value back to the 'minLimit'.
59+
// If the user clicks away with the input empty or invalid, restore the last valid value
60+
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
61+
const input = event.target;
62+
if (!isValid(input)) {
63+
setDisplayValue(lastValidValue.toString());
64+
props.onValidityChange?.(true);
65+
}
66+
};
67+
4068
return (
4169
<div className="smallNumberPicker">
4270
<div data-tip={props.tooltip}>
4371
<TextField
72+
css={css`
73+
/* Don't display the little up/down arrows for number input */
74+
input[type="number"] {
75+
-moz-appearance: textfield;
76+
}
77+
input[type="number"]::-webkit-outer-spin-button,
78+
input[type="number"]::-webkit-inner-spin-button {
79+
-webkit-appearance: none;
80+
margin: 0;
81+
}
82+
83+
input[type="number"] {
84+
text-align: right;
85+
}
86+
`}
87+
onBlur={handleBlur}
4488
onChange={handleNumberChange}
45-
value={chosenNumber}
89+
value={displayValue}
90+
type="number"
91+
inputProps={{
92+
min: minimumValue,
93+
max: props.maxLimit,
94+
step: 1,
95+
}}
4696
variant="standard"
4797
/>
4898
</div>

src/BloomBrowserUI/react_components/stories/misc.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export const SmallNumberPickerStory: Story = {
5656
console.log("We handled change!");
5757
console.log(` result was ${newNumber}`);
5858
};
59+
const onValidityChange = (isValid: boolean) => {
60+
console.log(`Input validity changed: ${isValid}`);
61+
};
5962
const min = 1;
6063
const max = 15;
6164

@@ -78,6 +81,7 @@ export const SmallNumberPickerStory: Story = {
7881
minLimit={min}
7982
maxLimit={max}
8083
handleChange={onHandleChange}
84+
onValidityChange={onValidityChange}
8185
tooltip={numberOfPagesTooltip}
8286
/>
8387
</div>

0 commit comments

Comments
 (0)