From a30d697b8d9f21173531a4327ad6a12cc1758420 Mon Sep 17 00:00:00 2001 From: Emily Arnold Date: Wed, 4 Mar 2026 18:28:23 +0000 Subject: [PATCH 1/4] Add NumberInput, tests, and storybook component --- .../controls/NumberInput.stories.tsx | 81 ++++++++++ src/components/controls/NumberInput.test.tsx | 142 +++++++++++++++++ src/components/controls/NumberInput.tsx | 146 ++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 src/components/controls/NumberInput.stories.tsx create mode 100644 src/components/controls/NumberInput.test.tsx create mode 100644 src/components/controls/NumberInput.tsx diff --git a/src/components/controls/NumberInput.stories.tsx b/src/components/controls/NumberInput.stories.tsx new file mode 100644 index 0000000..2fb106e --- /dev/null +++ b/src/components/controls/NumberInput.stories.tsx @@ -0,0 +1,81 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { NumberInput } from "./NumberInput"; + +const meta: Meta = { + title: "Components/Controls/NumberInput", + component: NumberInput, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const handleCommit = (number: number, parameters?: object) => { + alert(JSON.stringify({ number, parameters })); +}; + +export const Input: Story = {}; + +export const DefaultNumberWithLabel: Story = { + args: { label: "A floating point number" }, +}; + +export const InvalidDefaultNumber: Story = { + args: { + label: "An invalid default number", + numberMode: "natural", + defaultValue: 15.2, + }, +}; + +export const NaturalNumberWithLimits: Story = { + args: { + label: "A natural number", + numberMode: "natural", + defaultValue: 1, + minValue: 0, + maxValue: 15, + }, +}; + +export const IntegerNumber: Story = { + args: { + label: "An integer number", + numberMode: "integer", + defaultValue: -1, + }, +}; + +export const FloatingNumberWithLimits: Story = { + args: { + label: "A floating point number", + numberMode: "floating", + defaultValue: 1.1, + minValue: -50, + maxValue: 50, + }, +}; + +export const ScientificNumber: Story = { + args: { + label: "A scientific number", + numberMode: "scientific", + defaultValue: 1e5, + }, +}; + +export const DefaultNumberWithOnlyReturnKeySubmission: Story = { + args: { + label: "A floating point number", + onCommit: handleCommit, + commitOnBlur: false, + }, +}; + +export const DefaultNumberWithOnlyBlurSubmission: Story = { + args: { + label: "A floating point number", + onCommit: handleCommit, + commitOnReturn: false, + }, +}; diff --git a/src/components/controls/NumberInput.test.tsx b/src/components/controls/NumberInput.test.tsx new file mode 100644 index 0000000..5ce752b --- /dev/null +++ b/src/components/controls/NumberInput.test.tsx @@ -0,0 +1,142 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { NumberInput } from "./NumberInput"; + +describe("NumberInput", () => { + it("default value is marked invalid", async () => { + render( + , + ); + expect(screen.queryByText("Invalid input")).toBeInTheDocument(); + }); + + it("default value is marked valid", async () => { + render( + , + ); + expect(screen.queryByText("Invalid input")).not.toBeInTheDocument(); + }); + + it("is marked valid when limits are present", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "0" } }); + expect(screen.queryByText("Invalid input")).not.toBeInTheDocument(); + }); + + it("is marked invalid when a lower limit is exceeded", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "-1" } }); + expect(screen.queryByText("Invalid input")).toBeInTheDocument(); + }); + + it("is marked invalid when an upper limit is exceeded", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "15" } }); + expect(screen.queryByText("Invalid input")).toBeInTheDocument(); + }); + + it("does not accept negative numbers in natural mode", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "-5" } }); + expect(screen.queryByText("Invalid input")).toBeInTheDocument(); + }); + + it("accepts positive numbers in natural mode", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "5" } }); + expect(screen.queryByText("Invalid input")).not.toBeInTheDocument(); + }); + + it("does not accept decimal numbers in integer mode", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "-5.2" } }); + expect(screen.queryByText("Invalid input")).toBeInTheDocument(); + }); + + it("accepts negative numbers in integer mode", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "-5" } }); + expect(screen.queryByText("Invalid input")).not.toBeInTheDocument(); + }); + + it("does not accept scientific numbers in floating mode", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "-5e5" } }); + expect(screen.queryByText("Invalid input")).toBeInTheDocument(); + }); + + it("accepts decimal numbers in floating mode", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "-5.2" } }); + expect(screen.queryByText("Invalid input")).not.toBeInTheDocument(); + }); + + it("does not accept non-number characters in scientific mode", async () => { + render( + , + ); + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "-5e5!" } }); + expect(screen.queryByText("Invalid input")).toBeInTheDocument(); + }); + + it("accepts scientific numbers in scientific mode", async () => { + render( + , + ); + + const numberInput = screen.getByLabelText("numberbox"); + fireEvent.change(numberInput, { target: { value: "5e5" } }); + expect(screen.queryByText("Invalid input")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/controls/NumberInput.tsx b/src/components/controls/NumberInput.tsx new file mode 100644 index 0000000..655ce17 --- /dev/null +++ b/src/components/controls/NumberInput.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { TextField } from "@mui/material"; + +const Modes = { + /** Natural numbers from 0 to inf */ + natural: /^([0-9]+)$/, + /** Integers from -inf to inf */ + integer: /^[+\\-]?([0-9]+)$/, + /** Floating point numbers from -inf to inf, accepts values such as 1. and .1 as valid*/ + floating: + /^[+\\-]?(([0-9]+)|([0-9]+[\\.])|([\\.][0-9]+)|([0-9]+[\\.][0-9]+))$/, + /** Floating point numbers from -inf to inf, accepts values such as 1.e1 and .1e1 as valid*/ + scientific: + /^[+\\-]?(([0-9]+)|([0-9]+[\\.])|([\\.][0-9]+)|([0-9]+[\\.][0-9]+))([eE][+\\-]?[0-9]+)?$/, +}; + +interface NumberInputTextProps { + label: string; + numberMode: keyof typeof Modes; + numberText: string; + setNumberText: (v: string) => void; + isValid: boolean; + setIsValid: (v: boolean) => void; + handleCommit?: () => void; + commitOnReturn?: boolean; + commitOnBlur?: boolean; + minValue?: number; + maxValue?: number; +} + +const NumberInputText: React.FC = ({ + label, + numberMode, + numberText, + setNumberText, + isValid, + setIsValid, + handleCommit, + commitOnReturn, + commitOnBlur, + minValue, + maxValue, +}) => { + const numberRegex = Modes[numberMode]; + + const helperText = `A ${numberMode} number. Limits: ${minValue} to ${maxValue}`; + + const checkLimits = (value: string) => { + if (minValue && maxValue) { + return parseFloat(value) >= minValue && parseFloat(value) <= maxValue; + } else { + return true; + } + }; + + const handleInputChange = (value: string) => { + setIsValid(numberRegex.test(value) && checkLimits(value)); + setNumberText(value); + }; + + const handleKeyDown = (event: { key: string }) => { + if (event.key === "Enter" && commitOnReturn && isValid && handleCommit) { + handleCommit(); + } + }; + + const handleBlur = () => { + if (isValid && commitOnBlur && handleCommit) { + handleCommit(); + } + }; + + return ( + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + error={!isValid} + helperText={!isValid ? "Invalid input" : helperText} + variant="outlined" + /> + ); +}; + +interface NumberInputProps { + label: string; + numberMode: keyof typeof Modes; + defaultValue: number | string; + onCommit?: (number: number) => void; + number?: number; + parameters?: object; + commitOnReturn?: boolean; + commitOnBlur?: boolean; + minValue?: number; + maxValue?: number; +} + +const NumberInput: React.FC = ({ + label, + numberMode = "floating", + defaultValue, + onCommit, + commitOnReturn = true, + commitOnBlur = true, + minValue = -Infinity, + maxValue = Infinity, +}) => { + const [numberText, setNumberText] = useState( + !defaultValue ? "" : defaultValue.toString(), + ); + const [isValid, setIsValid] = useState( + !defaultValue ? true : Modes[numberMode].test(defaultValue.toString()), + ); + + const handleCommit = () => { + const parsedValue: number = parseFloat(numberText); + if (onCommit) { + onCommit(parsedValue); + } + }; + + return ( + <> + { + + } + + ); +}; + +export { NumberInput }; +export type { NumberInputProps }; From 8808b53cc79de2978b2fc143068d13c2902713f6 Mon Sep 17 00:00:00 2001 From: Emily Arnold Date: Thu, 5 Mar 2026 08:54:06 +0000 Subject: [PATCH 2/4] Add to index.ts, add documentation to storybook --- .../controls/NumberInput.stories.tsx | 91 +++++++++++++++++-- src/components/controls/NumberInput.tsx | 2 +- src/index.ts | 1 + 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/components/controls/NumberInput.stories.tsx b/src/components/controls/NumberInput.stories.tsx index 2fb106e..c5138db 100644 --- a/src/components/controls/NumberInput.stories.tsx +++ b/src/components/controls/NumberInput.stories.tsx @@ -5,26 +5,56 @@ const meta: Meta = { title: "Components/Controls/NumberInput", component: NumberInput, tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "A number input field, which validates by number mode and limits.", + }, + }, + }, }; export default meta; type Story = StoryObj; -const handleCommit = (number: number, parameters?: object) => { - alert(JSON.stringify({ number, parameters })); +const handleCommit = (number: number) => { + alert(JSON.stringify({ number })); }; -export const Input: Story = {}; +export const Input: Story = { + parameters: { + docs: { + description: { + story: "Default number input field.", + }, + }, + }, +}; -export const DefaultNumberWithLabel: Story = { +export const NumberWithLabel: Story = { args: { label: "A floating point number" }, + parameters: { + docs: { + description: { + story: "Number input field with a label.", + }, + }, + }, }; export const InvalidDefaultNumber: Story = { args: { label: "An invalid default number", - numberMode: "natural", - defaultValue: 15.2, + defaultValue: "15.2e5", + }, + parameters: { + docs: { + description: { + story: + "Number input field with a label and an invalid default value given.", + }, + }, }, }; @@ -36,6 +66,13 @@ export const NaturalNumberWithLimits: Story = { minValue: 0, maxValue: 15, }, + parameters: { + docs: { + description: { + story: "Number input field with natural number mode and given limits.", + }, + }, + }, }; export const IntegerNumber: Story = { @@ -44,6 +81,13 @@ export const IntegerNumber: Story = { numberMode: "integer", defaultValue: -1, }, + parameters: { + docs: { + description: { + story: "Number input field with integer number mode.", + }, + }, + }, }; export const FloatingNumberWithLimits: Story = { @@ -54,28 +98,57 @@ export const FloatingNumberWithLimits: Story = { minValue: -50, maxValue: 50, }, + parameters: { + docs: { + description: { + story: + "Number input field with floating point number mode and given limits.", + }, + }, + }, }; export const ScientificNumber: Story = { args: { label: "A scientific number", numberMode: "scientific", - defaultValue: 1e5, + defaultValue: "1e5", + }, + parameters: { + docs: { + description: { + story: "Number input field with scientific number mode.", + }, + }, }, }; -export const DefaultNumberWithOnlyReturnKeySubmission: Story = { +export const NumberWithOnlyReturnKeyCommit: Story = { args: { label: "A floating point number", onCommit: handleCommit, commitOnBlur: false, }, + parameters: { + docs: { + description: { + story: "Number input field with commit on return.", + }, + }, + }, }; -export const DefaultNumberWithOnlyBlurSubmission: Story = { +export const NumberWithOnlyBlurCommit: Story = { args: { label: "A floating point number", onCommit: handleCommit, commitOnReturn: false, }, + parameters: { + docs: { + description: { + story: "Number input field with commit on blur.", + }, + }, + }, }; diff --git a/src/components/controls/NumberInput.tsx b/src/components/controls/NumberInput.tsx index 655ce17..94b1520 100644 --- a/src/components/controls/NumberInput.tsx +++ b/src/components/controls/NumberInput.tsx @@ -104,7 +104,7 @@ const NumberInput: React.FC = ({ onCommit, commitOnReturn = true, commitOnBlur = true, - minValue = -Infinity, + minValue = numberMode == "natural" ? 0 : -Infinity, maxValue = Infinity, }) => { const [numberText, setNumberText] = useState( diff --git a/src/index.ts b/src/index.ts index 9b8dda1..e37e627 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export * from "./components/controls/ProgressDelayed"; export * from "./components/controls/User"; export * from "./components/controls/ScrollableImages"; export * from "./components/controls/VisitInput"; +export * from "./components/controls/NumberInput"; // components/systems export * from "./components/systems/auth"; From cf32abc97e15b812ab224a012e58d1d3f94d0f23 Mon Sep 17 00:00:00 2001 From: Emily Arnold Date: Mon, 9 Mar 2026 10:29:24 +0000 Subject: [PATCH 3/4] Tidy storybook --- src/components/controls/NumberInput.stories.tsx | 1 + src/components/controls/NumberInput.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/controls/NumberInput.stories.tsx b/src/components/controls/NumberInput.stories.tsx index c5138db..7e0c78b 100644 --- a/src/components/controls/NumberInput.stories.tsx +++ b/src/components/controls/NumberInput.stories.tsx @@ -16,6 +16,7 @@ const meta: Meta = { }; export default meta; + type Story = StoryObj; const handleCommit = (number: number) => { diff --git a/src/components/controls/NumberInput.tsx b/src/components/controls/NumberInput.tsx index 94b1520..3776a62 100644 --- a/src/components/controls/NumberInput.tsx +++ b/src/components/controls/NumberInput.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import React, { useState } from "react"; import { TextField } from "@mui/material"; const Modes = { @@ -86,8 +86,8 @@ const NumberInputText: React.FC = ({ interface NumberInputProps { label: string; - numberMode: keyof typeof Modes; - defaultValue: number | string; + numberMode?: keyof typeof Modes; + defaultValue?: number | string; onCommit?: (number: number) => void; number?: number; parameters?: object; @@ -98,7 +98,7 @@ interface NumberInputProps { } const NumberInput: React.FC = ({ - label, + label = "", numberMode = "floating", defaultValue, onCommit, From d98fcb5dafb8cbc995d28b2a7016702518556481 Mon Sep 17 00:00:00 2001 From: Emily Arnold Date: Mon, 9 Mar 2026 10:44:07 +0000 Subject: [PATCH 4/4] fix limit validation with '0' --- src/components/controls/NumberInput.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/components/controls/NumberInput.tsx b/src/components/controls/NumberInput.tsx index 3776a62..67cd6ad 100644 --- a/src/components/controls/NumberInput.tsx +++ b/src/components/controls/NumberInput.tsx @@ -24,8 +24,8 @@ interface NumberInputTextProps { handleCommit?: () => void; commitOnReturn?: boolean; commitOnBlur?: boolean; - minValue?: number; - maxValue?: number; + minValue: number; + maxValue: number; } const NumberInputText: React.FC = ({ @@ -46,11 +46,7 @@ const NumberInputText: React.FC = ({ const helperText = `A ${numberMode} number. Limits: ${minValue} to ${maxValue}`; const checkLimits = (value: string) => { - if (minValue && maxValue) { - return parseFloat(value) >= minValue && parseFloat(value) <= maxValue; - } else { - return true; - } + return parseFloat(value) >= minValue && parseFloat(value) <= maxValue; }; const handleInputChange = (value: string) => {