diff --git a/src/__snapshots__/Storyshots.test.js.snap b/src/__snapshots__/Storyshots.test.js.snap
index b3e796c609..8c7fe118d8 100644
--- a/src/__snapshots__/Storyshots.test.js.snap
+++ b/src/__snapshots__/Storyshots.test.js.snap
@@ -6564,6 +6564,7 @@ exports[`Storyshots Forms/Form builder Default 1`] = `
className="form-control"
disabled={false}
id="root_age"
+ inputMode="numeric"
name="root_age"
onBlur={[Function]}
onChange={[Function]}
@@ -6571,8 +6572,7 @@ exports[`Storyshots Forms/Form builder Default 1`] = `
placeholder=""
readOnly={false}
required={false}
- step={1}
- type="number"
+ type="text"
value=""
/>
.
+ */
+
+import CustomFormComponent from "@/bricks/renderers/CustomFormComponent";
+import {
+ normalizeOutgoingFormData,
+ normalizeIncomingFormData,
+} from "@/bricks/renderers/customForm";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { type Schema } from "@/types/schemaTypes";
+import { type JsonObject } from "type-fest";
+
+describe("CustomFormComponent", () => {
+ test("renders a text input with inputmode numeric in place of a number input", () => {
+ const schema: Schema = {
+ type: "object",
+ properties: {
+ rating: { type: "number", title: "Rating" },
+ },
+ };
+
+ const data = {};
+
+ // This is what we'd send to server
+ const outgoingData = normalizeOutgoingFormData(schema, data);
+
+ // This is what we feed to the form
+ const normalizedData = normalizeIncomingFormData(
+ schema,
+ outgoingData,
+ ) as JsonObject;
+
+ render(
+ ,
+ );
+
+ expect(
+ // Hidden:true because Stylesheets component sets hidden unless all stylesheets are loaded
+ screen.getByRole("textbox", { name: "Rating", hidden: true }),
+ ).toHaveAttribute("inputmode", "numeric");
+
+ expect(screen.queryByRole("spinButton")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap b/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap
index b2f937cdb8..d5a42d9f99 100644
--- a/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap
+++ b/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap
@@ -57,10 +57,10 @@ exports[`form data normalization renders normalized data 1`] = `
aria-describedby="root_age__error root_age__description root_age__help"
class="form-control"
id="root_age"
+ inputmode="numeric"
name="root_age"
placeholder=""
- step="1"
- type="number"
+ type="text"
value=""
/>
@@ -121,10 +121,10 @@ exports[`form data normalization renders normalized data 1`] = `
aria-describedby="root_rating__error root_rating__description root_rating__help"
class="form-control"
id="root_rating"
+ inputmode="numeric"
name="root_rating"
placeholder=""
- step="any"
- type="number"
+ type="text"
value=""
/>
diff --git a/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx b/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx
index 18c7f8e018..8b3fae364a 100644
--- a/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx
+++ b/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx
@@ -104,4 +104,28 @@ describe("EphemeralForm", () => {
).toBeInTheDocument();
expect(screen.getByRole("strong")).toHaveTextContent("bold");
});
+
+ it("renders a text input with inputmode numeric in place of a number input", async () => {
+ getFormDefinitionMock.mockResolvedValue({
+ schema: {
+ title: "Test Form",
+ type: "object",
+ properties: {
+ rating: { type: "number", title: "Rating" },
+ },
+ },
+ uiSchema: {},
+ cancelable: false,
+ submitCaption: "Submit",
+ location: "modal",
+ });
+
+ render();
+
+ await expect(
+ screen.findByRole("textbox", { name: "Rating", hidden: true }),
+ ).resolves.toHaveAttribute("inputmode", "numeric");
+
+ expect(screen.queryByRole("spinButton")).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/formBuilder/BaseInputTemplate.test.tsx b/src/components/formBuilder/BaseInputTemplate.test.tsx
new file mode 100644
index 0000000000..d408794fc9
--- /dev/null
+++ b/src/components/formBuilder/BaseInputTemplate.test.tsx
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2024 PixieBrix, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import BaseInputTemplate, {
+ type StrictBaseInputTemplateProps,
+} from "@/components/formBuilder/BaseInputTemplate";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { type JSONSchema7 } from "json-schema";
+import React from "react";
+
+describe("RJSF BaseInputTemplate Override", () => {
+ function getProps(id: string, schema: JSONSchema7, type?: string) {
+ return {
+ value: "",
+ id,
+ name: id,
+ schema,
+ options: {},
+ label: "",
+ type,
+ onBlur: jest.fn(),
+ onChange: jest.fn(),
+ onFocus: jest.fn(),
+ // @ts-expect-error -- Required by type, not used by component
+ registry: undefined,
+ } satisfies StrictBaseInputTemplateProps;
+ }
+
+ it("renders a standard text input when the type is text", () => {
+ const schema = { title: "Text", type: "string" } as JSONSchema7;
+
+ render();
+
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ });
+
+ it("renders a text input with type email when the format is email", () => {
+ const schema = {
+ title: "Email",
+ type: "string",
+ format: "email",
+ } as JSONSchema7;
+
+ render();
+
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toHaveAttribute("type", "email");
+ });
+
+ it("renders a text input with type url when the format is url", () => {
+ const schema = {
+ title: "URL",
+ type: "string",
+ format: "url",
+ } as JSONSchema7;
+
+ render();
+
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toHaveAttribute("type", "url");
+ });
+
+ it("renders a file input when the format is data-url", () => {
+ const schema = {
+ title: "File",
+ type: "string",
+ format: "data-url",
+ } as JSONSchema7;
+
+ const { container } = render(
+ ,
+ );
+
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ const input = container.querySelector("#url");
+
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute("type", "file");
+ });
+
+ it("renders a date picker when the format is date", () => {
+ const schema = {
+ title: "Date",
+ type: "string",
+ format: "date",
+ } as JSONSchema7;
+
+ const { container } = render(
+ ,
+ );
+
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ const input = container.querySelector("#date");
+
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute("type", "date");
+ });
+
+ it("renders a date-time picker when the format is date-time", () => {
+ const schema = {
+ title: "Date-Time",
+ type: "string",
+ format: "date-time",
+ } as JSONSchema7;
+
+ const { container } = render(
+ ,
+ );
+
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ const input = container.querySelector("#dateTime");
+
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute("type", "datetime-local");
+ });
+
+ it("renders a standard text input with inputMode numeric and a regex pattern when the type is number", () => {
+ const schema = { title: "Number", type: "number" } as JSONSchema7;
+
+ render();
+
+ expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toHaveAttribute("type", "text");
+ expect(screen.getByRole("textbox")).toHaveAttribute("inputMode", "numeric");
+ });
+
+ it.each([
+ [0, "0", "2", 2],
+ [1, "1", "2", 12],
+ [1.5, "1.5", "2", 1.52],
+ [1, "1", ".05", 1.05],
+ [-1, "-1", "2", -12],
+ [1.045e25, "1.045e+25", "2", 1.045e252],
+ [undefined, "", "2", 2],
+ [1.045, "1.045", "e25", 1.045e25],
+ ])(
+ "when number %d is passed as the value, it is converted to string %s; when the onChange is called with string %s, it is converted to number %d",
+ async (value, inputValue, typedValue, calledWith) => {
+ const schema = { title: "Number", type: "number" } as JSONSchema7;
+ const onChange = jest.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox")).toHaveValue(inputValue);
+
+ await userEvent.type(screen.getByRole("textbox"), typedValue);
+
+ expect(screen.getByRole("textbox")).toHaveValue(inputValue + typedValue);
+ expect(onChange).toHaveBeenCalledWith(calledWith);
+ },
+ );
+
+ it("numeric input ignores keystrokes that are not valid numbers", async () => {
+ const schema = { title: "Number", type: "number" } as JSONSchema7;
+ const onChange = jest.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox")).toHaveValue("");
+
+ await userEvent.type(screen.getByRole("textbox"), "abc123");
+
+ expect(screen.getByRole("textbox")).toHaveValue("123");
+ expect(onChange).toHaveBeenCalledWith(123);
+ });
+
+ it("numeric input does not lose decimal when the value is changed", async () => {
+ const schema = { title: "Number", type: "number" } as JSONSchema7;
+ const onChange = jest.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox")).toHaveValue("1");
+
+ await userEvent.type(screen.getByRole("textbox"), ".");
+ await userEvent.type(screen.getByRole("textbox"), "0");
+ await userEvent.type(screen.getByRole("textbox"), "5");
+
+ expect(screen.getByRole("textbox")).toHaveValue("1.05");
+
+ expect(onChange).toHaveBeenCalledWith(1.05);
+ });
+
+ it("numeric input can be cleared", async () => {
+ const schema = { title: "Number", type: "number" } as JSONSchema7;
+ const onChange = jest.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox")).toHaveValue("1");
+
+ await userEvent.type(screen.getByRole("textbox"), "{backspace}");
+
+ expect(screen.getByRole("textbox")).toHaveValue("");
+
+ expect(onChange).toHaveBeenCalledWith(Number.NaN);
+ });
+});
diff --git a/src/components/formBuilder/BaseInputTemplate.tsx b/src/components/formBuilder/BaseInputTemplate.tsx
new file mode 100644
index 0000000000..5f4468af34
--- /dev/null
+++ b/src/components/formBuilder/BaseInputTemplate.tsx
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2024 PixieBrix, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { type ChangeEvent, type FocusEvent, useState } from "react";
+import {
+ ariaDescribedByIds,
+ type BaseInputTemplateProps,
+ examplesId,
+ type FormContextType,
+ getInputProps,
+ type StrictRJSFSchema,
+ type InputPropsType,
+ type RJSFSchema,
+} from "@rjsf/utils";
+import React from "react";
+import { FormControl, type FormControlProps } from "react-bootstrap";
+import { type UnknownObject } from "@/types/objectTypes";
+
+// RJSF's BaseInputTemplateProps is overly permissive. Tightening it up here.
+export interface StrictBaseInputTemplateProps<
+ T = HTMLInputElement,
+ S extends StrictRJSFSchema = RJSFSchema,
+ F extends FormContextType = UnknownObject,
+> extends BaseInputTemplateProps {
+ extraProps?: InputPropsType;
+ type?: string;
+ value: string | number;
+}
+
+// eslint-disable-next-line security/detect-unsafe-regex
+const DEFAULT_NUMBER_REGEX = /^-?\d*(?:\.\d*)?(?:[Ee][+-]?\d*)?$/;
+
+/* @since 1.8.7
+ * Used for number inputs to store the value as a string
+ * to avoid losing decimals during the conversion to number
+ */
+function useNumericInput<
+ T = HTMLInputElement,
+ S extends StrictRJSFSchema = RJSFSchema,
+ F extends FormContextType = UnknownObject,
+>({
+ value,
+ extraProps,
+ schema,
+ type,
+ options,
+}: {
+ value: string | number;
+ extraProps?: InputPropsType;
+ schema: S;
+ type?: string;
+ options: StrictBaseInputTemplateProps["options"];
+}) {
+ const [storedValue, setStoredValue] = useState(value?.toString() ?? "");
+
+ const inputProps: FormControlProps & {
+ step?: number | "any";
+ inputMode?: "numeric";
+ } = {
+ ...extraProps,
+ ...getInputProps(schema, type, options),
+ };
+
+ // Converting number inputs to text inputs with numeric inputMode
+ // Removes the spinner to improve UX
+ // See https://github.com/pixiebrix/pixiebrix-extension/issues/7343
+ if (inputProps.type === "number") {
+ inputProps.step = undefined;
+ inputProps.type = "text";
+ inputProps.inputMode = "numeric";
+ }
+
+ return { storedValue, setStoredValue, inputProps };
+}
+
+function getValue(
+ value: string | number,
+ storedValue?: string | number,
+ type?: "numeric",
+): string | number {
+ if (type === "numeric") {
+ return storedValue ?? "";
+ }
+
+ return value || value === 0 ? value : "";
+}
+
+export default function BaseInputTemplate<
+ T = HTMLInputElement,
+ S extends StrictRJSFSchema = RJSFSchema,
+ F extends FormContextType = UnknownObject,
+>({
+ id,
+ placeholder,
+ required,
+ readonly,
+ disabled,
+ type,
+ value,
+ onChange,
+ onChangeOverride,
+ onBlur,
+ onFocus,
+ autofocus,
+ options,
+ schema,
+ rawErrors = [],
+ children,
+ extraProps,
+}: StrictBaseInputTemplateProps) {
+ const { storedValue, setStoredValue, inputProps } = useNumericInput({
+ value,
+ extraProps,
+ schema,
+ type,
+ options,
+ });
+
+ const _onChange = ({ target: { value } }: ChangeEvent) => {
+ let _value: string | number = value;
+
+ if (
+ inputProps.inputMode === "numeric" &&
+ // Tried to pass regex as a pattern, but it didn't prevent invalid keystrokes
+ DEFAULT_NUMBER_REGEX.test(value)
+ ) {
+ setStoredValue(value);
+ _value = Number.parseFloat(value);
+ }
+
+ onChange(_value === "" ? options.emptyValue : _value);
+ };
+
+ const _onBlur = ({ target: { value } }: FocusEvent) => {
+ onBlur(id, value);
+ };
+
+ const _onFocus = ({ target: { value } }: FocusEvent) => {
+ onFocus(id, value);
+ };
+
+ return (
+ <>
+ 0 ? "is-invalid" : ""}
+ list={schema.examples ? examplesId(id) : undefined}
+ {...inputProps}
+ value={getValue(value, storedValue, inputProps.inputMode)}
+ onChange={onChangeOverride || _onChange}
+ onBlur={_onBlur}
+ onFocus={_onFocus}
+ aria-describedby={ariaDescribedByIds(id, Boolean(schema.examples))}
+ />
+ {children}
+ {Array.isArray(schema.examples) ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/components/formBuilder/RjsfTemplates.ts b/src/components/formBuilder/RjsfTemplates.ts
index eaec5d272c..4a3b78898d 100644
--- a/src/components/formBuilder/RjsfTemplates.ts
+++ b/src/components/formBuilder/RjsfTemplates.ts
@@ -15,6 +15,7 @@
* along with this program. If not, see .
*/
+import BaseInputTemplate from "@/components/formBuilder/BaseInputTemplate";
import DescriptionFieldTemplate from "@/components/formBuilder/DescriptionFieldTemplate";
import FieldTemplate from "@/components/formBuilder/FieldTemplate";
import { type FormProps } from "@rjsf/core";
@@ -22,4 +23,5 @@ import { type FormProps } from "@rjsf/core";
export const templates = {
FieldTemplate,
DescriptionFieldTemplate,
+ BaseInputTemplate,
} satisfies FormProps["templates"];
diff --git a/src/components/formBuilder/preview/FormPreview.test.tsx b/src/components/formBuilder/preview/FormPreview.test.tsx
index 84559c913b..3a378dca94 100644
--- a/src/components/formBuilder/preview/FormPreview.test.tsx
+++ b/src/components/formBuilder/preview/FormPreview.test.tsx
@@ -127,4 +127,29 @@ describe("FormPreview", () => {
expect(screen.getByText("A note")).toBeInTheDocument();
expect(screen.queryByText("notes")).not.toBeInTheDocument();
});
+
+ test("renders a text input with inputmode numeric in place of a number input", async () => {
+ const schema: Schema = {
+ title: "Form",
+ type: "object",
+ properties: {
+ rating: { type: "number", title: "Rating" },
+ },
+ };
+ const uiSchema: UiSchema = {};
+
+ const props: FormPreviewProps = {
+ rjsfSchema: { schema, uiSchema },
+ activeField: "notes",
+ setActiveField: defaultProps.setActiveField,
+ };
+
+ render();
+
+ await expect(
+ screen.findByRole("textbox", { name: "Rating", hidden: true }),
+ ).resolves.toHaveAttribute("inputmode", "numeric");
+
+ expect(screen.queryByRole("spinButton")).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap b/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap
index 9109a20a76..45550bd9d6 100644
--- a/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap
+++ b/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap
@@ -184,10 +184,10 @@ exports[`FormPreview it renders simple schema 1`] = `
aria-describedby="root_age__error root_age__description root_age__help"
class="form-control"
id="root_age"
+ inputmode="numeric"
name="root_age"
placeholder=""
- step="any"
- type="number"
+ type="text"
value=""
/>
diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json
index 2e8ac43805..3d5124b0d7 100644
--- a/src/tsconfig.strictNullChecks.json
+++ b/src/tsconfig.strictNullChecks.json
@@ -526,6 +526,7 @@
"./vendors/reactPerformanceTesting/utils/pushTask.ts",
"./vendors/reactPerformanceTesting/utils/shouldTrack.ts",
"./vendors/reactPerformanceTesting/utils/symbols.ts",
- "./vendors/validateUuid.ts"
+ "./vendors/validateUuid.ts",
+ "./components/formBuilder/BaseInputTemplate.tsx"
]
}