Skip to content

Commit ef1d764

Browse files
committed
fix(form): Floating Label for controlled value Invalid numbers
This also fixes the weird case where the user does a flow like: - type "0123-" - tab / blur field - refocus field - delete the entire string in one keystroke - tab / blur field Apparently change events do not get fired for this flow since `"0123-" === ""` so going from `"" -> ""` doesn't fire a change event....
1 parent e452aff commit ef1d764

File tree

3 files changed

+186
-17
lines changed

3 files changed

+186
-17
lines changed

packages/form/src/text-field/TextField.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -162,15 +162,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
162162
) {
163163
const { id, value, defaultValue } = props;
164164

165-
const [focused, onFocus, onBlur] = useFocusState({
165+
const [focused, onFocus, handleBlur] = useFocusState({
166166
onBlur: propOnBlur,
167167
onFocus: propOnFocus,
168168
});
169169

170-
const [valued, onChange] = useValuedState({
170+
const [valued, onChange, onBlur] = useValuedState<HTMLInputElement>({
171171
value,
172172
defaultValue,
173173
onChange: propOnChange,
174+
onBlur: handleBlur,
174175
});
175176

176177
const { theme, underlineDirection } = useFormTheme({
@@ -231,6 +232,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
231232
}
232233
);
233234

235+
/* istanbul ignore next */
234236
if (process.env.NODE_ENV !== "production") {
235237
try {
236238
const PropTypes = require("prop-types");

packages/form/src/text-field/__tests__/TextField.tsx

+141-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { ReactElement, useState } from "react";
22
import { fireEvent, render } from "@testing-library/react";
33

44
import { TextField } from "../TextField";
@@ -69,6 +69,95 @@ describe("TextField", () => {
6969
expect(label.className).toContain("rmd-floating-label--inactive");
7070
});
7171

72+
it("should add the inactive floating label state when a number text field is blurred while containing an invalid value when controlled", () => {
73+
function Test(): ReactElement {
74+
const [value, setValue] = useState("");
75+
76+
return (
77+
<TextField
78+
id="text-field"
79+
label="Label"
80+
type="number"
81+
value={value}
82+
onChange={(event) => setValue(event.currentTarget.value)}
83+
/>
84+
);
85+
}
86+
const { getByRole, getByText } = render(<Test />);
87+
88+
const field = getByRole("spinbutton") as HTMLInputElement;
89+
const label = getByText("Label");
90+
expect(field).toHaveAttribute("value", "");
91+
expect(label.className).not.toContain("rmd-floating-label--active");
92+
expect(label.className).not.toContain("rmd-floating-label--inactive");
93+
94+
fireEvent.focus(field);
95+
expect(label.className).toContain("rmd-floating-label--active");
96+
expect(label.className).not.toContain("rmd-floating-label--inactive");
97+
98+
fireEvent.change(field, { target: { value: "123" } });
99+
expect(label.className).toContain("rmd-floating-label--active");
100+
expect(label.className).not.toContain("rmd-floating-label--inactive");
101+
102+
// TODO: Look into writing real browser tests since this isn't implemented in JSDOM
103+
Object.defineProperty(field.validity, "badInput", {
104+
writable: true,
105+
value: true,
106+
});
107+
expect(field.validity.badInput).toBe(true);
108+
fireEvent.change(field, {
109+
target: { value: "123-" },
110+
});
111+
expect(field.validity.badInput).toBe(true);
112+
expect(label.className).toContain("rmd-floating-label--active");
113+
expect(label.className).not.toContain("rmd-floating-label--inactive");
114+
115+
fireEvent.blur(field);
116+
expect(label.className).toContain("rmd-floating-label--active");
117+
expect(label.className).toContain("rmd-floating-label--inactive");
118+
});
119+
120+
it("should add the floating inactive state for a number field that is initially rendered with a value", () => {
121+
const onBlur = jest.fn();
122+
function Test(): ReactElement {
123+
const [value, setValue] = useState("0");
124+
125+
return (
126+
<TextField
127+
id="text-field"
128+
label="Label"
129+
type="number"
130+
value={value}
131+
onBlur={onBlur}
132+
onChange={(event) => setValue(event.currentTarget.value)}
133+
/>
134+
);
135+
}
136+
const { getByRole, getByText } = render(<Test />);
137+
138+
const field = getByRole("spinbutton") as HTMLInputElement;
139+
const label = getByText("Label");
140+
expect(field).toHaveAttribute("value", "0");
141+
expect(label.className).toContain("rmd-floating-label--active");
142+
expect(label.className).toContain("rmd-floating-label--inactive");
143+
144+
fireEvent.focus(field);
145+
fireEvent.change(field, { target: { value: "" } });
146+
fireEvent.blur(field);
147+
expect(onBlur).toBeCalledTimes(1);
148+
expect(label.className).not.toContain("rmd-floating-label--active");
149+
expect(label.className).not.toContain("rmd-floating-label--inactive");
150+
151+
fireEvent.focus(field);
152+
fireEvent.change(field, { target: { value: "3" } });
153+
fireEvent.change(field, { target: { value: "3-" } });
154+
fireEvent.change(field, { target: { value: "3" } });
155+
fireEvent.blur(field);
156+
expect(onBlur).toBeCalledTimes(2);
157+
expect(label.className).toContain("rmd-floating-label--active");
158+
expect(label.className).toContain("rmd-floating-label--inactive");
159+
});
160+
72161
it("should not add the inactive floating label state when a non-number type has a badInput validity", () => {
73162
const { getByRole, getByText } = render(
74163
<TextField id="text-field" label="Label" type="url" defaultValue="" />
@@ -108,4 +197,55 @@ describe("TextField", () => {
108197
expect(label.className).not.toContain("rmd-floating-label--active");
109198
expect(label.className).not.toContain("rmd-floating-label--inactive");
110199
});
200+
201+
it("should not add the inactive floating label state when a non-number type has a badInput validity when controlled", () => {
202+
function Test(): ReactElement {
203+
const [value, setValue] = useState("");
204+
205+
return (
206+
<TextField
207+
id="text-field"
208+
label="Label"
209+
type="url"
210+
value={value}
211+
onChange={(event) => setValue(event.currentTarget.value)}
212+
/>
213+
);
214+
}
215+
const { getByRole, getByText } = render(<Test />);
216+
217+
const field = getByRole("textbox") as HTMLInputElement;
218+
const label = getByText("Label");
219+
expect(field).toHaveAttribute("value", "");
220+
expect(label.className).not.toContain("rmd-floating-label--active");
221+
expect(label.className).not.toContain("rmd-floating-label--inactive");
222+
223+
fireEvent.focus(field);
224+
expect(label.className).toContain("rmd-floating-label--active");
225+
expect(label.className).not.toContain("rmd-floating-label--inactive");
226+
227+
// TODO: Look into writing real browser tests since this isn't implemented in JSDOM
228+
Object.defineProperty(field.validity, "badInput", {
229+
writable: true,
230+
value: true,
231+
});
232+
fireEvent.change(field, { target: { value: "123" } });
233+
expect(field.validity.badInput).toBe(true);
234+
expect(label.className).toContain("rmd-floating-label--active");
235+
expect(label.className).not.toContain("rmd-floating-label--inactive");
236+
237+
fireEvent.blur(field);
238+
expect(field.validity.badInput).toBe(true);
239+
expect(label.className).toContain("rmd-floating-label--active");
240+
expect(label.className).toContain("rmd-floating-label--inactive");
241+
242+
fireEvent.focus(field);
243+
expect(label.className).toContain("rmd-floating-label--active");
244+
expect(label.className).not.toContain("rmd-floating-label--inactive");
245+
246+
fireEvent.change(field, { target: { value: "" } });
247+
fireEvent.blur(field);
248+
expect(label.className).not.toContain("rmd-floating-label--active");
249+
expect(label.className).not.toContain("rmd-floating-label--inactive");
250+
});
111251
});
+41-14
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useCallback } from "react";
1+
import { FocusEvent, FocusEventHandler, useCallback } from "react";
22
import { useRefCache, useToggle } from "@react-md/utils";
33

44
type TextElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
55
type Value = string | number | (string | number)[];
66
type ChangeEventHandler<T extends TextElement> = React.ChangeEventHandler<T>;
77

88
interface Options<T extends TextElement> {
9+
onBlur?: FocusEventHandler<T>;
910
onChange?: ChangeEventHandler<T>;
1011
value?: Value;
1112
defaultValue?: Value;
@@ -18,10 +19,11 @@ interface Options<T extends TextElement> {
1819
* @private
1920
*/
2021
export function useValuedState<T extends TextElement>({
22+
onBlur,
2123
onChange,
2224
value,
2325
defaultValue,
24-
}: Options<T>): [boolean, ChangeEventHandler<T> | undefined] {
26+
}: Options<T>): [boolean, ChangeEventHandler<T>, FocusEventHandler<T>] {
2527
const handler = useRefCache(onChange);
2628
const [valued, enable, disable] = useToggle(() => {
2729
if (typeof value === "undefined") {
@@ -30,8 +32,11 @@ export function useValuedState<T extends TextElement>({
3032
);
3133
}
3234

33-
// this isn't used for controlled components
34-
return false;
35+
if (typeof value === "string") {
36+
return value.length > 0;
37+
}
38+
39+
return typeof value === "number";
3540
});
3641

3742
const handleChange = useCallback<React.ChangeEventHandler<T>>(
@@ -41,12 +46,17 @@ export function useValuedState<T extends TextElement>({
4146
onChange(event);
4247
}
4348

44-
if (event.currentTarget.value.length > 0) {
49+
const input = event.currentTarget;
50+
if (input.getAttribute("type") === "number") {
51+
input.checkValidity();
52+
if (input.validity.badInput) {
53+
return;
54+
}
55+
}
56+
57+
if (input.value.length > 0) {
4558
enable();
46-
} else if (
47-
event.currentTarget.getAttribute("type") !== "number" ||
48-
!event.currentTarget.validity.badInput
49-
) {
59+
} else {
5060
disable();
5161
}
5262
},
@@ -55,10 +65,27 @@ export function useValuedState<T extends TextElement>({
5565
[enable, disable]
5666
);
5767

58-
if (typeof value !== "undefined") {
59-
const isValued = typeof value === "number" || value.length > 0;
60-
return [isValued, onChange];
61-
}
68+
// This is **really** only for TextField components and the input
69+
// type="number". When there is a badInput, a change event does not get fired
70+
// again once it is "fixed" or emptied.
71+
const handleBlur = useCallback(
72+
(event: FocusEvent<T>) => {
73+
if (onBlur) {
74+
onBlur(event);
75+
}
76+
77+
const input = event.currentTarget;
78+
if (input.getAttribute("type") === "number") {
79+
input.checkValidity();
80+
if (input.validity.badInput || input.value.length > 0) {
81+
return;
82+
}
83+
84+
disable();
85+
}
86+
},
87+
[onBlur, disable]
88+
);
6289

63-
return [valued, handleChange];
90+
return [valued, handleChange, handleBlur];
6491
}

0 commit comments

Comments
 (0)