diff --git a/apps/examples/ui-test-app/src/tests/Banner.test.tsx b/apps/examples/ui-test-app/src/tests/Banner.test.tsx
index 575d33f04..148d8101c 100644
--- a/apps/examples/ui-test-app/src/tests/Banner.test.tsx
+++ b/apps/examples/ui-test-app/src/tests/Banner.test.tsx
@@ -1,21 +1,9 @@
-import { ThemeProvider } from "@emotion/react";
import { Banner } from "@lightsparkdev/ui/components";
-import { themes } from "@lightsparkdev/ui/styles/themes";
import { link } from "@lightsparkdev/ui/utils/toReactNodes/nodes";
-import { screen, render as tlRender, waitFor } from "@testing-library/react";
-import type { ReactElement, ReactNode } from "react";
+import { screen, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { TestAppRoutes } from "../types";
-
-function Providers({ children }: { children: ReactNode }) {
- return {children};
-}
-
-function render(renderElement: ReactElement) {
- return tlRender(renderElement, {
- wrapper: Providers,
- });
-}
+import { render } from "./render";
describe("Banner", () => {
test("should properly infer argument types and raise errors for invalid values", async () => {
diff --git a/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx b/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx
index 3fe609da0..249173fdc 100644
--- a/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx
+++ b/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx
@@ -1,24 +1,7 @@
-import { ThemeProvider } from "@emotion/react";
import { jest } from "@jest/globals";
import { CodeInput } from "@lightsparkdev/ui/components/CodeInput/CodeInput";
-import { themes } from "@lightsparkdev/ui/styles/themes";
-import {
- fireEvent,
- screen,
- render as tlRender,
- waitFor,
-} from "@testing-library/react";
-import { type ReactElement, type ReactNode } from "react";
-
-function Providers({ children }: { children: ReactNode }) {
- return {children};
-}
-
-function render(renderElement: ReactElement) {
- return tlRender(renderElement, {
- wrapper: Providers,
- });
-}
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { render } from "./render";
describe("CodeInput", () => {
beforeEach(() => {
diff --git a/apps/examples/ui-test-app/src/tests/NumberInput.core.test.tsx b/apps/examples/ui-test-app/src/tests/NumberInput.core.test.tsx
new file mode 100644
index 000000000..a692b1380
--- /dev/null
+++ b/apps/examples/ui-test-app/src/tests/NumberInput.core.test.tsx
@@ -0,0 +1,163 @@
+import { jest } from "@jest/globals";
+import { NumberInput } from "@lightsparkdev/ui/components/NumberInput";
+import "@testing-library/jest-dom";
+import { fireEvent, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { render } from "./render";
+
+describe("NumberInput core", () => {
+ const onChangeSpy = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("Renders without error", () => {
+ render(
+ {}}
+ />,
+ );
+ const input = screen.getByRole("textbox");
+
+ expect(input).toHaveValue("");
+ });
+
+ it("Renders with value", () => {
+ render( {}} />);
+ const input = screen.getByRole("textbox");
+
+ expect(input).toHaveValue("£1,234.56");
+ });
+
+ it("Renders with value 0", () => {
+ render( {}} />);
+
+ expect(screen.getByRole("textbox")).toHaveValue("£0");
+ });
+
+ it("Renders with value 0 with decimalScale 2", () => {
+ render(
+ {}} />,
+ );
+
+ expect(screen.getByRole("textbox")).toHaveValue("£0.00");
+ });
+
+ it("Renders with value prop", () => {
+ render( {}} />);
+
+ expect(screen.getByRole("textbox")).toHaveValue("£49.99");
+ });
+
+ it("Renders with value 0.1 with decimalScale 2", async () => {
+ render(
+ {}}
+ />,
+ );
+
+ expect(screen.getByRole("textbox")).toHaveValue("£0.10");
+
+ await userEvent.type(screen.getByRole("textbox"), "{backspace}");
+
+ expect(screen.getByRole("textbox")).toHaveValue("£0.1");
+ });
+
+ it("should prefix 0 value", () => {
+ render();
+
+ expect(screen.getByRole("textbox")).toHaveValue("£0");
+ });
+
+ it("should allow empty value", async () => {
+ const { rerender } = render(
+ ,
+ );
+ await userEvent.clear(screen.getByRole("textbox"));
+
+ expect(onChangeSpy).toHaveBeenLastCalledWith("", {
+ float: null,
+ formatted: "",
+ value: "",
+ });
+ rerender();
+ expect(screen.getByRole("textbox")).toHaveValue("");
+ });
+
+ it("should not allow invalid characters", async () => {
+ render();
+ await userEvent.type(screen.getByRole("textbox"), "hello");
+
+ expect(onChangeSpy).toHaveBeenLastCalledWith("", {
+ float: null,
+ formatted: "",
+ value: "",
+ });
+
+ expect(screen.getByRole("textbox")).toHaveValue("");
+ });
+
+ it("should clear decimal point only input", async () => {
+ render();
+ await userEvent.type(screen.getByRole("textbox"), ".");
+
+ expect(onChangeSpy).toHaveBeenLastCalledWith("", {
+ float: null,
+ formatted: "",
+ value: "",
+ });
+
+ fireEvent.focusOut(screen.getByRole("textbox"));
+ expect(screen.getByRole("textbox")).toHaveValue("");
+ });
+
+ it("should allow .3 decimal inputs", async () => {
+ const { rerender } = render(
+ ,
+ );
+ await userEvent.type(screen.getByRole("textbox"), ".3");
+
+ expect(onChangeSpy).toHaveBeenLastCalledWith(".3", {
+ float: 0.3,
+ formatted: "£0.3",
+ value: ".3",
+ });
+
+ rerender();
+ fireEvent.focusOut(screen.getByRole("textbox"));
+ expect(screen.getByRole("textbox")).toHaveValue("£0.3");
+ });
+
+ it("should update the input when prop value changes to another number", () => {
+ const { rerender } = render(
+ {}}
+ />,
+ );
+
+ const field = screen.getByRole("textbox");
+ expect(field).toHaveValue("£1");
+
+ rerender(
+ {}}
+ />,
+ );
+
+ expect(field).toHaveValue("£2");
+ });
+});
diff --git a/apps/examples/ui-test-app/src/tests/CommaNumberInput.test.tsx b/apps/examples/ui-test-app/src/tests/NumberInput.test.tsx
similarity index 69%
rename from apps/examples/ui-test-app/src/tests/CommaNumberInput.test.tsx
rename to apps/examples/ui-test-app/src/tests/NumberInput.test.tsx
index 59b366758..6d3346464 100644
--- a/apps/examples/ui-test-app/src/tests/CommaNumberInput.test.tsx
+++ b/apps/examples/ui-test-app/src/tests/NumberInput.test.tsx
@@ -1,34 +1,22 @@
-import { ThemeProvider } from "@emotion/react";
-import { CommaNumberInput } from "@lightsparkdev/ui/components/CommaNumberInput";
-import { themes } from "@lightsparkdev/ui/styles/themes";
-import { screen, render as tlRender } from "@testing-library/react";
+import { NumberInput } from "@lightsparkdev/ui/components/NumberInput";
+import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import type { ReactElement, ReactNode } from "react";
+import { render } from "./render";
-function Providers({ children }: { children: ReactNode }) {
- return {children};
-}
-
-function render(renderElement: ReactElement) {
- return tlRender(renderElement, {
- wrapper: Providers,
- });
-}
-
-describe("CommaNumberInput", () => {
+describe("NumberInput", () => {
const defaultOnChange = () => {};
test("should not add commas to numbers less than 1000", () => {
- render();
+ render();
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123");
});
test("should add commas", () => {
- render();
+ render();
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("12,354");
});
test("should add multiple commas for large values", () => {
- render();
+ render();
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456,789");
});
@@ -38,25 +26,24 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
await userEvent.type(el, "7");
expect(value).toBe("1234567");
- rerender();
+ rerender();
expect(el).toHaveValue("1,234,567");
});
test("should have expected cursor position when adding values to end", async () => {
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
await userEvent.type(el, "7");
- rerender();
+ rerender();
expect(el).toHaveValue("1,234,567");
- expect(el.selectionStart).toBe(9);
});
test("should have expected cursor position when adding values to beginning", async () => {
let value = "";
@@ -64,7 +51,7 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
@@ -74,9 +61,8 @@ describe("CommaNumberInput", () => {
initialSelectionEnd: 0,
});
expect(value).toBe("7123456");
- rerender();
+ rerender();
expect(el).toHaveValue("7,123,456");
- expect(el.selectionStart).toBe(2);
});
test("should have expected cursor position when adding values to middle", async () => {
let value = "";
@@ -84,7 +70,7 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
@@ -94,9 +80,8 @@ describe("CommaNumberInput", () => {
initialSelectionEnd: 2,
});
expect(value).toBe("1273456");
- rerender();
+ rerender();
expect(el).toHaveValue("1,273,456");
- expect(el.selectionStart).toBe(4);
});
test("backspace should delete proper character when not comma", async () => {
let value = "";
@@ -104,7 +89,7 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
@@ -114,9 +99,8 @@ describe("CommaNumberInput", () => {
initialSelectionEnd: 2,
});
expect(value).toBe("13456");
- rerender();
+ rerender();
expect(el).toHaveValue("13,456");
- expect(el.selectionStart).toBe(1);
});
test("backspace should delete proper character when comma", async () => {
let value = "";
@@ -124,7 +108,7 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
@@ -134,9 +118,8 @@ describe("CommaNumberInput", () => {
initialSelectionEnd: 4,
});
expect(value).toBe("12456");
- rerender();
+ rerender();
expect(el).toHaveValue("12,456");
- expect(el.selectionStart).toBe(2);
});
test("delete key should delete proper character when not comma", async () => {
let value = "";
@@ -144,7 +127,7 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
@@ -154,9 +137,8 @@ describe("CommaNumberInput", () => {
initialSelectionEnd: 2,
});
expect(value).toBe("12456");
- rerender();
+ rerender();
expect(el).toHaveValue("12,456");
- expect(el.selectionStart).toBe(2);
});
test("delete key should delete proper character when comma", async () => {
let value = "";
@@ -164,7 +146,7 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456");
@@ -174,9 +156,8 @@ describe("CommaNumberInput", () => {
initialSelectionEnd: 3,
});
expect(value).toBe("12356");
- rerender();
+ rerender();
expect(el).toHaveValue("12,356");
- expect(el.selectionStart).toBe(4);
});
test("deleting range of characters including comma", async () => {
let value = "";
@@ -184,7 +165,7 @@ describe("CommaNumberInput", () => {
value = newValue;
}
const { rerender } = render(
- ,
+ ,
);
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("123,456,789");
@@ -194,16 +175,15 @@ describe("CommaNumberInput", () => {
initialSelectionEnd: 5,
});
expect(value).toBe("156789");
- rerender();
+ rerender();
expect(el).toHaveValue("156,789");
- expect(el.selectionStart).toBe(1);
});
test("paste should trigger on change", async () => {
let value = "";
function onChange(newValue: string) {
value = newValue;
}
- render();
+ render();
const el = screen.getByPlaceholderText("Enter a number");
expect(el).toHaveValue("");
await userEvent.click(el);
diff --git a/apps/examples/ui-test-app/src/tests/TextIconAligner.test.tsx b/apps/examples/ui-test-app/src/tests/TextIconAligner.test.tsx
index c98a6fff3..6e07a695e 100644
--- a/apps/examples/ui-test-app/src/tests/TextIconAligner.test.tsx
+++ b/apps/examples/ui-test-app/src/tests/TextIconAligner.test.tsx
@@ -1,21 +1,9 @@
-import { ThemeProvider } from "@emotion/react";
import { TextIconAligner } from "@lightsparkdev/ui/components";
-import { themes } from "@lightsparkdev/ui/styles/themes";
import { link } from "@lightsparkdev/ui/utils/toReactNodes/nodes";
-import { screen, render as tlRender, waitFor } from "@testing-library/react";
-import type { ReactElement, ReactNode } from "react";
+import { screen, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { TestAppRoutes } from "../types";
-
-function Providers({ children }: { children: ReactNode }) {
- return {children};
-}
-
-function render(renderElement: ReactElement) {
- return tlRender(renderElement, {
- wrapper: Providers,
- });
-}
+import { render } from "./render";
describe("TextIconAligner", () => {
test("should properly infer argument types and raise errors for invalid values", async () => {
diff --git a/apps/examples/ui-test-app/src/tests/Toasts.test.tsx b/apps/examples/ui-test-app/src/tests/Toasts.test.tsx
index ceffa1f88..7d7352248 100644
--- a/apps/examples/ui-test-app/src/tests/Toasts.test.tsx
+++ b/apps/examples/ui-test-app/src/tests/Toasts.test.tsx
@@ -1,19 +1,7 @@
-import { ThemeProvider } from "@emotion/react";
import { Toasts } from "@lightsparkdev/ui/components";
-import { themes } from "@lightsparkdev/ui/styles/themes";
-import { screen, render as tlRender, waitFor } from "@testing-library/react";
-import type { ReactElement, ReactNode } from "react";
+import { screen, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
-
-function Providers({ children }: { children: ReactNode }) {
- return {children};
-}
-
-function render(renderElement: ReactElement) {
- return tlRender(renderElement, {
- wrapper: Providers,
- });
-}
+import { render } from "./render";
describe("Toasts", () => {
test("should properly infer argument types and raise errors for invalid values", async () => {
diff --git a/apps/examples/ui-test-app/src/tests/render.tsx b/apps/examples/ui-test-app/src/tests/render.tsx
new file mode 100644
index 000000000..5f8d94f16
--- /dev/null
+++ b/apps/examples/ui-test-app/src/tests/render.tsx
@@ -0,0 +1,14 @@
+import { ThemeProvider } from "@emotion/react";
+import { themes } from "@lightsparkdev/ui/styles/themes";
+import { render as tlRender } from "@testing-library/react";
+import type { ReactElement, ReactNode } from "react";
+
+function Providers({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+export function render(renderElement: ReactElement) {
+ return tlRender(renderElement, {
+ wrapper: Providers,
+ });
+}
diff --git a/packages/ui/src/components/CommaNumberInput.tsx b/packages/ui/src/components/CommaNumberInput.tsx
deleted file mode 100644
index 0884c9642..000000000
--- a/packages/ui/src/components/CommaNumberInput.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved
-import { clamp } from "@lightsparkdev/core";
-import type { ReactElement } from "react";
-import {
- useCallback,
- useEffect,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-import {
- addCommasToDigits,
- addCommasToVariableDecimal,
- removeChars,
- removeCommas,
- removeLeadingZeros,
- removeNonDigit,
-} from "../utils/strings.js";
-import { TextInput, type TextInputProps } from "./TextInput.js";
-
-export type CommaNumberInputProps = Omit & {
- onChange: (newValue: string) => void;
- maxValue?: number | undefined;
- decimals?: number | undefined;
-};
-
-export function CommaNumberInput(props: CommaNumberInputProps): ReactElement {
- const { onChange, value, decimals = 0, ...rest } = props;
- const inputRef: React.MutableRefObject =
- useRef(null);
- const cursorIndex = useRef(null);
- const [valueWithCommaDecimal, setValueWithCommaDecimal] = useState(value);
-
- const handleOnChange = useCallback(
- (newValue: string, event: React.ChangeEvent | null) => {
- event?.preventDefault();
- if (!inputRef.current) {
- return;
- }
- const cursorPosition = inputRef.current.selectionStart || 0;
- const existingCommasRemoved = removeNonDigit(newValue);
- const validInputChange = existingCommasRemoved.match(/^\d*$/);
- if (validInputChange) {
- onChange(existingCommasRemoved);
- const leadingZerosRemoved = removeLeadingZeros(existingCommasRemoved);
- const decimalIfRelevant =
- decimals > 0
- ? (Number(leadingZerosRemoved) / 10 ** decimals).toFixed(decimals)
- : leadingZerosRemoved;
- const newValueWithCommaDecimal = addCommasToDigits(decimalIfRelevant);
- const cursorDistanceFromEnd = newValue.length - cursorPosition;
- cursorIndex.current =
- newValueWithCommaDecimal.length - cursorDistanceFromEnd;
- }
- },
- [onChange, decimals],
- );
-
- useEffect(() => {
- const existingCommasRemoved = removeCommas(value);
- const leadingZerosRemoved = removeLeadingZeros(existingCommasRemoved);
- setValueWithCommaDecimal(
- addCommasToDigits(
- decimals > 0
- ? (Number(leadingZerosRemoved) / 10 ** decimals).toFixed(decimals)
- : leadingZerosRemoved,
- ),
- );
- }, [value, decimals]);
-
- useLayoutEffect(() => {
- if (cursorIndex.current !== null) {
- inputRef.current?.setSelectionRange(
- cursorIndex.current,
- cursorIndex.current,
- );
- cursorIndex.current = null;
- }
- }, [valueWithCommaDecimal]);
-
- const handleKeyDown = useCallback(
- (keyValue: string, event: React.KeyboardEvent) => {
- if (!inputRef.current) {
- return;
- }
- const hasModifiers = event.ctrlKey || event.metaKey || event.altKey;
- const isBackspace = keyValue === "Backspace";
- if ((isBackspace || keyValue === "Delete") && !hasModifiers) {
- event.preventDefault();
- const selectionStart = inputRef.current.selectionStart || 0;
- const selectionEnd = inputRef.current.selectionEnd || 0;
- let newValue = "";
- let newCursorIndex = selectionStart;
- if (selectionStart === selectionEnd) {
- // deleting one character
- if (selectionStart === 0 && isBackspace) {
- return;
- }
- // if backspacing - the index we want to delete is 1 before current pos.
- const toDeleteIdx = selectionStart + (isBackspace ? -1 : 0);
- const isCommaDecimal = [",", "."].includes(
- valueWithCommaDecimal[toDeleteIdx],
- );
- const deleteCharIndex = isBackspace
- ? Math.max(selectionStart - (isCommaDecimal ? 2 : 1), 0)
- : selectionStart + (isCommaDecimal ? 1 : 0);
- newValue = addCommasToVariableDecimal(
- removeChars(valueWithCommaDecimal, deleteCharIndex),
- decimals,
- );
- const diff = valueWithCommaDecimal.length - newValue.length;
- if (diff > 1) {
- // comma and number removed. move cursor back.
- newCursorIndex = deleteCharIndex - 1;
- } else if (diff === 1) {
- newCursorIndex = deleteCharIndex;
- } else if (diff === 0) {
- // when decimals are present, there will be a fixed min length.
- // Cursor needs to get incremented to account.
- // given: 0.1X1 backspace at X, deleteCharIndex is 2. cursor is at 3.
- // after backspace, 0.0X1 is expected, where cursor position is still 3.
- newCursorIndex = deleteCharIndex + 1;
- }
- } else {
- // deleting a range of characters
- newValue = addCommasToVariableDecimal(
- removeChars(valueWithCommaDecimal, selectionStart, selectionEnd),
- decimals,
- );
- }
- onChange(removeNonDigit(newValue));
- cursorIndex.current = clamp(newCursorIndex, 0, newValue.length);
- }
- },
- [valueWithCommaDecimal, decimals, onChange],
- );
-
- const { maxValue, onRightButtonClick } = props;
- const handleRightButtonClick = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- if (maxValue) {
- handleOnChange(maxValue.toString(), null);
- }
- if (onRightButtonClick) {
- onRightButtonClick(e);
- }
- },
- [handleOnChange, maxValue, onRightButtonClick],
- );
- return (
-
- );
-}
-
-export default CommaNumberInput;
diff --git a/packages/ui/src/components/NumberInput.tsx b/packages/ui/src/components/NumberInput.tsx
new file mode 100644
index 000000000..0ccf10b78
--- /dev/null
+++ b/packages/ui/src/components/NumberInput.tsx
@@ -0,0 +1,83 @@
+// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved
+import { useCallback, useRef } from "react";
+import { type UseNumberInputArgs } from "../hooks/useNumberInput/types.js";
+import { useNumberInput } from "../hooks/useNumberInput/useNumberInput.js";
+import { TextInput, type TextInputProps } from "./TextInput.js";
+
+export type NumberInputProps = Omit &
+ Omit & {
+ onChange: UseNumberInputArgs["onChange"];
+ maxValue?: number | undefined;
+ };
+
+export function NumberInput({
+ maxValue,
+ onRightButtonClick,
+ onChange: onChangeProp,
+ ...rest
+}: NumberInputProps) {
+ const ref = useRef(null);
+ const {
+ inputRef,
+ getRenderValue,
+ handleOnChange,
+ handleOnBlur,
+ handleOnFocus,
+ handleOnKeyDown,
+ handleOnKeyUp,
+ } = useNumberInput({
+ ...rest,
+ onChange: onChangeProp,
+ ref,
+ });
+
+ const onChange = useCallback(
+ (value, event) => {
+ handleOnChange(event);
+ },
+ [handleOnChange],
+ );
+
+ const handleRightButtonClick = useCallback<
+ NonNullable
+ >(
+ (event) => {
+ event.preventDefault();
+ if (maxValue && onChangeProp) {
+ onChangeProp(maxValue.toString());
+ }
+ if (onRightButtonClick) {
+ onRightButtonClick(event);
+ }
+ },
+ [maxValue, onRightButtonClick, onChangeProp],
+ );
+
+ const onKeyDown = useCallback>(
+ (keyValue, event) => {
+ handleOnKeyDown(event);
+ },
+ [handleOnKeyDown],
+ );
+
+ return (
+
+ );
+}
+
+export default NumberInput;
diff --git a/packages/ui/src/components/SatoshiInput.tsx b/packages/ui/src/components/SatoshiInput.tsx
index 4b3cfa92a..599b52e47 100644
--- a/packages/ui/src/components/SatoshiInput.tsx
+++ b/packages/ui/src/components/SatoshiInput.tsx
@@ -1,14 +1,12 @@
// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved
import { Fragment } from "react";
-import CommaNumberInput, {
- type CommaNumberInputProps,
-} from "./CommaNumberInput.js";
+import NumberInput, { type NumberInputProps } from "./NumberInput.js";
import {
SatoshiInputLabel,
type SatoshiInputLabelProps,
} from "./SatoshiInputLabel.js";
-type SatoshiInputProps = Omit & {
+type SatoshiInputProps = Omit & {
maxValue?: number;
label?: SatoshiInputLabelProps;
};
@@ -28,9 +26,10 @@ export function SatoshiInput({
showTooltip={label.showTooltip}
/>
)}
- void;
- onChange: (
- newValue: string,
- event: React.ChangeEvent,
- ) => void;
+ onBlur?: (event: FocusEvent) => void;
+ onChange: (newValue: string, event: ChangeEvent) => void;
onEnter?: () => void;
- onFocus?: () => void;
+ onFocus?: (event: FocusEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
onKeyDown?: (
keyValue: string,
- event: React.KeyboardEvent,
+ event: KeyboardEvent,
) => void;
+ onKeyUp?: (event: KeyboardEvent) => void;
placeholder?: string;
inputRef?: RefObject | undefined;
inputRefCb?: RefCallback;
@@ -86,7 +86,7 @@ export type TextInputProps = {
| undefined;
onBeforeInput?: (e: CompositionEvent) => void;
pattern?: string;
- inputMode?: "numeric" | undefined;
+ inputMode?: "numeric" | "decimal" | undefined;
hint?: string | undefined;
hintTooltip?: string | undefined;
label?: string;
@@ -183,10 +183,10 @@ export function TextInput(textInputProps: TextInputProps) {
maxLength={props.maxLength}
inputMode={props.inputMode}
pattern={props.pattern}
- onBlur={() => {
+ onBlur={(blurEvent) => {
setFocused(false);
if (props.onBlur) {
- props.onBlur();
+ props.onBlur(blurEvent);
}
}}
onChange={(e) => {
@@ -194,10 +194,10 @@ export function TextInput(textInputProps: TextInputProps) {
e.target.setCustomValidity("");
props.onChange(e.target.value, e);
}}
- onFocus={() => {
+ onFocus={(focusEvent) => {
setFocused(true);
if (props.onFocus) {
- props.onFocus();
+ props.onFocus(focusEvent);
}
}}
onKeyDown={(e) => {
@@ -206,6 +206,7 @@ export function TextInput(textInputProps: TextInputProps) {
}
handleKeyDown(e);
}}
+ onKeyUp={props.onKeyUp}
id={props.id}
onPaste={props.onPaste}
placeholder={props.placeholder}
diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx
index 2f3a82110..370f6e14a 100644
--- a/packages/ui/src/components/index.tsx
+++ b/packages/ui/src/components/index.tsx
@@ -41,10 +41,6 @@ export {
} from "./CodeInput/CodeInput.js";
export { Collapsible, StyledCollapsible } from "./Collapsible.js";
export { CommandKey } from "./CommandKey.js";
-export {
- CommaNumberInput,
- type CommaNumberInputProps,
-} from "./CommaNumberInput.js";
export { CopyToClipboardButton } from "./CopyToClipboardButton.js";
export { CurrencyAmount, CurrencyIcon } from "./CurrencyAmount.js";
export {
@@ -64,6 +60,7 @@ export { LightboxImage } from "./LightboxImage.js";
export { LightsparkProvider } from "./LightsparkProvider.js";
export { Loading, LoadingWrapper } from "./Loading.js";
export { Modal } from "./Modal.js";
+export { NumberInput, type NumberInputProps } from "./NumberInput.js";
export {
PageSection,
PageSectionBox,
diff --git a/packages/ui/src/hooks/useNumberInput/types.ts b/packages/ui/src/hooks/useNumberInput/types.ts
new file mode 100644
index 000000000..c6c50cdd0
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/types.ts
@@ -0,0 +1,109 @@
+import { type ComponentPropsWithRef } from "react";
+
+/** Value in different formats */
+export type UseNumberInputOnChangeValues = {
+ /**
+ * Value as float or null if empty
+ *
+ * Example:
+ * "1.99" > 1.99
+ * "" > null
+ */
+ float: number | null;
+
+ /**
+ * Value after applying formatting
+ *
+ * Example: "1000000" > "1,000,0000"
+ */
+ formatted: string;
+
+ /** Non formatted value as string */
+ value: string;
+};
+
+export type IntlConfig = {
+ locale: string;
+ currency?: string;
+};
+
+type InputProps = ComponentPropsWithRef<"input">;
+
+export type UseNumberInputArgs = {
+ /** Allow decimals */
+ allowDecimals?: boolean | undefined;
+ /** Allow user to enter negative value */
+ allowNegativeValue?: boolean | undefined;
+ /** Maximum characters the user can enter */
+ maxLength?: number | undefined;
+ /** Limit length of decimals allowed */
+ decimalsLimit?: number | undefined;
+
+ /**
+ * Specify decimal scale for padding/trimming
+ *
+ * Example:
+ * 1.5 -> 1.50
+ * 1.234 -> 1.23
+ */
+ decimalScale?: number | undefined;
+ /** Default value if not passing in value via props */
+ defaultValue?: number | string | undefined;
+
+ /**
+ * Value will always have the specified length of decimals
+ *
+ * Example:
+ * 123 -> 1.23
+ *
+ * Note: This formatting only happens onBlur
+ */
+ fixedDecimalLength?: number | undefined;
+
+ /** Handle change in value */
+ onChange?:
+ | ((value: string, values?: UseNumberInputOnChangeValues) => void)
+ | undefined;
+
+ /** Include a prefix eg. £ */
+ prefix?: string | undefined;
+ /** Include a suffix eg. € */
+ suffix?: string | undefined;
+ /** Incremental value change on arrow down and arrow up key press */
+ step?: number | undefined;
+ /** Separator between integer part and fractional part of value. This cannot be a number */
+ decimalSeparator?: string | undefined;
+ /** Separator between thousand, million and billion. This cannot be a number */
+ groupSeparator?: string | undefined;
+
+ /** Disable auto adding separator between values eg. 1000 -> 1,000 */
+ disableGroupSeparators?: boolean | undefined;
+ /** Disable abbreviations (m, k, b) */
+ disableAbbreviations?: boolean | undefined;
+
+ /**
+ * International locale config, examples:
+ * { locale: 'ja-JP', currency: 'JPY' }
+ * { locale: 'en-IN', currency: 'INR' }
+ *
+ * Any prefix, groupSeparator or decimalSeparator options passed in
+ * will override Intl Locale config
+ */
+ intlConfig?: IntlConfig | undefined;
+
+ /** Transform the raw value form the input before parsing */
+ transformRawValue?: ((rawValue: string) => string) | undefined;
+
+ /**
+ * When set to false, the formatValueOnBlur flag disables the application of the
+ * __onChange__ function specifically on blur events. If disabled or set to false, the
+ * onChange will not trigger on blur. Default = true
+ */
+ formatValueOnBlur?: boolean | undefined;
+
+ /* text input props: */
+ ref?: InputProps["ref"];
+ min?: InputProps["min"];
+ max?: InputProps["max"];
+ value?: InputProps["value"];
+};
diff --git a/packages/ui/src/hooks/useNumberInput/useNumberInput.tsx b/packages/ui/src/hooks/useNumberInput/useNumberInput.tsx
new file mode 100644
index 000000000..d24d29bff
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/useNumberInput.tsx
@@ -0,0 +1,368 @@
+import type React from "react";
+import {
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ type UseNumberInputArgs,
+ type UseNumberInputOnChangeValues,
+} from "./types.js";
+import {
+ cleanValue,
+ type CleanValueOptions,
+ fixedDecimalValue,
+ formatValue,
+ type FormatValueOptions,
+ getLocaleConfig,
+ getSuffix,
+ isNumber,
+ padTrimValue,
+ repositionCursor,
+} from "./utils/index.js";
+
+export function useNumberInput({
+ allowDecimals = true,
+ allowNegativeValue = false,
+ decimalsLimit,
+ maxLength: userMaxLength,
+ value: userValue,
+ onChange,
+ fixedDecimalLength,
+ decimalScale,
+ prefix,
+ suffix,
+ intlConfig,
+ step,
+ min,
+ max,
+ disableGroupSeparators = false,
+ disableAbbreviations = false,
+ decimalSeparator: _decimalSeparator,
+ groupSeparator: _groupSeparator,
+ transformRawValue,
+ formatValueOnBlur = true,
+ ref,
+}: UseNumberInputArgs) {
+ if (_decimalSeparator && isNumber(_decimalSeparator)) {
+ throw new Error("decimalSeparator cannot be a number");
+ }
+
+ if (_groupSeparator && isNumber(_groupSeparator)) {
+ throw new Error("groupSeparator cannot be a number");
+ }
+
+ const localeConfig = useMemo(() => getLocaleConfig(intlConfig), [intlConfig]);
+ const decimalSeparator =
+ _decimalSeparator || localeConfig.decimalSeparator || "";
+ const groupSeparator = _groupSeparator || localeConfig.groupSeparator || "";
+
+ if (
+ decimalSeparator &&
+ groupSeparator &&
+ decimalSeparator === groupSeparator &&
+ disableGroupSeparators === false
+ ) {
+ throw new Error("decimalSeparator cannot be the same as groupSeparator");
+ }
+
+ const formatValueOptions: Partial = {
+ decimalSeparator,
+ groupSeparator,
+ disableGroupSeparators,
+ intlConfig,
+ prefix: prefix || localeConfig.prefix,
+ suffix: suffix,
+ };
+
+ const cleanValueOptions: Partial = {
+ decimalSeparator,
+ groupSeparator,
+ allowDecimals,
+ decimalsLimit: decimalsLimit || fixedDecimalLength || 2,
+ allowNegativeValue,
+ disableAbbreviations,
+ prefix: prefix || localeConfig.prefix,
+ transformRawValue,
+ };
+
+ const [stateValue, setStateValue] = useState(() =>
+ userValue != null
+ ? formatValue({
+ ...formatValueOptions,
+ decimalScale,
+ value: String(userValue),
+ })
+ : "",
+ );
+ const [dirty, setDirty] = useState(false);
+ const [cursor, setCursor] = useState(0);
+ const [changeCount, setChangeCount] = useState(0);
+ const [lastKeyStroke, setLastKeyStroke] = useState(null);
+ const inputRef = useRef(null);
+ useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
+
+ /**
+ * Process change in value
+ */
+ const processChange = (
+ value: string,
+ selectionStart?: number | null,
+ ): void => {
+ setDirty(true);
+
+ const { modifiedValue, cursorPosition } = repositionCursor({
+ selectionStart,
+ value,
+ lastKeyStroke,
+ stateValue,
+ groupSeparator,
+ });
+
+ const stringValue = cleanValue({
+ value: modifiedValue,
+ ...cleanValueOptions,
+ });
+
+ if (userMaxLength && stringValue.replace(/-/g, "").length > userMaxLength) {
+ return;
+ }
+
+ if (
+ stringValue === "" ||
+ stringValue === "-" ||
+ stringValue === decimalSeparator
+ ) {
+ onChange &&
+ onChange("", {
+ float: null,
+ formatted: "",
+ value: "",
+ });
+ setStateValue(stringValue);
+ // Always sets cursor after '-' or decimalSeparator input
+ setCursor(1);
+ return;
+ }
+
+ const stringValueWithoutSeparator = decimalSeparator
+ ? stringValue.replace(decimalSeparator, ".")
+ : stringValue;
+
+ const numberValue = parseFloat(stringValueWithoutSeparator);
+
+ const formattedValue = formatValue({
+ value: stringValue,
+ ...formatValueOptions,
+ });
+
+ if (cursorPosition != null) {
+ // Prevent cursor jumping
+ let newCursor = cursorPosition + (formattedValue.length - value.length);
+ newCursor = newCursor <= 0 ? (prefix ? prefix.length : 0) : newCursor;
+
+ setCursor(newCursor);
+ setChangeCount(changeCount + 1);
+ }
+
+ setStateValue(formattedValue);
+
+ if (onChange) {
+ const values: UseNumberInputOnChangeValues = {
+ float: numberValue,
+ formatted: formattedValue,
+ value: stringValue,
+ };
+ onChange(stringValue, values);
+ }
+ };
+
+ /**
+ * Handle change event
+ */
+ const handleOnChange = (event: React.ChangeEvent): void => {
+ const {
+ target: { value, selectionStart },
+ } = event;
+
+ processChange(value, selectionStart);
+ };
+
+ /**
+ * Handle focus event
+ */
+ const handleOnFocus = (event: React.FocusEvent): number => {
+ return stateValue ? stateValue.length : 0;
+ };
+
+ /**
+ * Handle blur event
+ *
+ * Format value by padding/trimming decimals if required by
+ */
+ const handleOnBlur = (event: React.FocusEvent): void => {
+ const {
+ target: { value },
+ } = event;
+
+ const valueOnly = cleanValue({ value, ...cleanValueOptions });
+
+ if (valueOnly === "-" || valueOnly === decimalSeparator || !valueOnly) {
+ setStateValue("");
+ return;
+ }
+
+ const fixedDecimals = fixedDecimalValue(
+ valueOnly,
+ decimalSeparator,
+ fixedDecimalLength,
+ );
+
+ const newValue = padTrimValue(
+ fixedDecimals,
+ decimalSeparator,
+ decimalScale !== undefined ? decimalScale : fixedDecimalLength,
+ );
+
+ const numberValue = parseFloat(newValue.replace(decimalSeparator, "."));
+
+ const formattedValue = formatValue({
+ ...formatValueOptions,
+ value: newValue,
+ });
+
+ if (onChange && formatValueOnBlur) {
+ onChange(newValue, {
+ float: numberValue,
+ formatted: formattedValue,
+ value: newValue,
+ });
+ }
+
+ setStateValue(formattedValue);
+ };
+
+ /**
+ * Handle key down event
+ *
+ * Increase or decrease value by step
+ */
+ const handleOnKeyDown = (event: React.KeyboardEvent) => {
+ const { key } = event;
+
+ setLastKeyStroke(key);
+
+ if (step && (key === "ArrowUp" || key === "ArrowDown")) {
+ event.preventDefault();
+ setCursor(stateValue.length);
+
+ const currentValue =
+ parseFloat(
+ userValue != null
+ ? String(userValue).replace(decimalSeparator, ".")
+ : cleanValue({ value: stateValue, ...cleanValueOptions }),
+ ) || 0;
+ const newValue =
+ key === "ArrowUp" ? currentValue + step : currentValue - step;
+
+ if (min !== undefined && newValue < Number(min)) {
+ return;
+ }
+
+ if (max !== undefined && newValue > Number(max)) {
+ return;
+ }
+
+ const fixedLength = String(step).includes(".")
+ ? Number(String(step).split(".")[1].length)
+ : undefined;
+
+ processChange(
+ String(fixedLength ? newValue.toFixed(fixedLength) : newValue).replace(
+ ".",
+ decimalSeparator,
+ ),
+ );
+ }
+ };
+
+ /**
+ * Handle key up event
+ *
+ * Move cursor if there is a suffix to prevent user typing past suffix
+ */
+ const handleOnKeyUp = (event: React.KeyboardEvent) => {
+ const {
+ key,
+ currentTarget: { selectionStart },
+ } = event;
+ if (key !== "ArrowUp" && key !== "ArrowDown" && stateValue !== "-") {
+ const suffix = getSuffix(stateValue, {
+ groupSeparator,
+ decimalSeparator,
+ });
+
+ if (
+ suffix &&
+ selectionStart &&
+ selectionStart > stateValue.length - suffix.length
+ ) {
+ if (inputRef.current) {
+ const newCursor = stateValue.length - suffix.length;
+ inputRef.current.setSelectionRange(newCursor, newCursor);
+ }
+ }
+ }
+ };
+
+ // Update state if userValue changes to undefined
+ useEffect(() => {
+ if (userValue == null) {
+ setStateValue("");
+ }
+ }, [userValue]);
+
+ useEffect(() => {
+ // prevent cursor jumping if editing value
+ if (
+ dirty &&
+ stateValue !== "-" &&
+ inputRef.current &&
+ document.activeElement === inputRef.current
+ ) {
+ inputRef.current.setSelectionRange(cursor, cursor);
+ }
+ }, [stateValue, cursor, inputRef, dirty, changeCount]);
+
+ /**
+ * If user has only entered "-" or decimal separator,
+ * keep the char to allow them to enter next value
+ */
+ const getRenderValue = () => {
+ if (
+ userValue != null &&
+ stateValue !== "-" &&
+ (!decimalSeparator || stateValue !== decimalSeparator)
+ ) {
+ return formatValue({
+ ...formatValueOptions,
+ decimalScale: dirty ? undefined : decimalScale,
+ value: String(userValue),
+ });
+ }
+
+ return stateValue;
+ };
+
+ return {
+ inputRef,
+ getRenderValue,
+ handleOnChange,
+ handleOnBlur,
+ handleOnFocus,
+ handleOnKeyDown,
+ handleOnKeyUp,
+ };
+}
diff --git a/packages/ui/src/hooks/useNumberInput/utils/addSeparators.ts b/packages/ui/src/hooks/useNumberInput/utils/addSeparators.ts
new file mode 100644
index 000000000..db7d3fa70
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/addSeparators.ts
@@ -0,0 +1,6 @@
+/**
+ * Add group separator to value eg. 1000 > 1,000
+ */
+export const addSeparators = (value: string, separator = ","): string => {
+ return value.replace(/\B(?=(\d{3})+(?!\d))/g, separator);
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/cleanValue.ts b/packages/ui/src/hooks/useNumberInput/utils/cleanValue.ts
new file mode 100644
index 000000000..3a13426ce
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/cleanValue.ts
@@ -0,0 +1,91 @@
+import { type UseNumberInputArgs } from "../types.js";
+import { escapeRegExp } from "./escapeRegExp.js";
+import { parseAbbrValue } from "./parseAbbrValue.js";
+import { removeInvalidChars } from "./removeInvalidChars.js";
+import { removeSeparators } from "./removeSeparators.js";
+
+export type CleanValueOptions = Pick<
+ UseNumberInputArgs,
+ | "decimalSeparator"
+ | "groupSeparator"
+ | "allowDecimals"
+ | "decimalsLimit"
+ | "allowNegativeValue"
+ | "disableAbbreviations"
+ | "prefix"
+ | "transformRawValue"
+> & { value: string };
+
+/**
+ * Remove prefix, separators and extra decimals from value
+ */
+export const cleanValue = ({
+ value,
+ groupSeparator = ",",
+ decimalSeparator = ".",
+ allowDecimals = true,
+ decimalsLimit = 2,
+ allowNegativeValue = true,
+ disableAbbreviations = false,
+ prefix = "",
+ transformRawValue = (rawValue) => rawValue,
+}: CleanValueOptions): string => {
+ const transformedValue = transformRawValue(value);
+
+ if (transformedValue === "-") {
+ return transformedValue;
+ }
+
+ const abbreviations = disableAbbreviations ? [] : ["k", "m", "b"];
+ const reg = new RegExp(`((^|\\D)-\\d)|(-${escapeRegExp(prefix)})`);
+ const isNegative = reg.test(transformedValue);
+
+ // Is there a digit before the prefix? eg. 1$
+ const [prefixWithValue, preValue] =
+ RegExp(`(\\d+)-?${escapeRegExp(prefix)}`).exec(value) || [];
+ const withoutPrefix = prefix
+ ? prefixWithValue
+ ? transformedValue.replace(prefixWithValue, "").concat(preValue)
+ : transformedValue.replace(prefix, "")
+ : transformedValue;
+ const withoutSeparators = removeSeparators(withoutPrefix, groupSeparator);
+ const withoutInvalidChars = removeInvalidChars(withoutSeparators, [
+ groupSeparator,
+ decimalSeparator,
+ ...abbreviations,
+ ]);
+
+ let valueOnly = withoutInvalidChars;
+
+ if (!disableAbbreviations) {
+ // disallow letter without number
+ if (
+ abbreviations.some(
+ (letter) =>
+ letter ===
+ withoutInvalidChars.toLowerCase().replace(decimalSeparator, ""),
+ )
+ ) {
+ return "";
+ }
+ const parsed = parseAbbrValue(withoutInvalidChars, decimalSeparator);
+ if (parsed) {
+ valueOnly = String(parsed);
+ }
+ }
+
+ const includeNegative = isNegative && allowNegativeValue ? "-" : "";
+
+ if (decimalSeparator && valueOnly.includes(decimalSeparator)) {
+ const [int, decimals] = withoutInvalidChars.split(decimalSeparator);
+ const trimmedDecimals =
+ decimalsLimit && decimals ? decimals.slice(0, decimalsLimit) : decimals;
+ const includeDecimals = allowDecimals
+ ? `${decimalSeparator}${trimmedDecimals}`
+ : "";
+
+ return `${includeNegative}${int}${includeDecimals}`;
+ }
+
+ return `${includeNegative}${valueOnly}`;
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/escapeRegExp.ts b/packages/ui/src/hooks/useNumberInput/utils/escapeRegExp.ts
new file mode 100644
index 000000000..b2b6e2428
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/escapeRegExp.ts
@@ -0,0 +1,8 @@
+/**
+ * Escape regex char
+ *
+ * See: https://stackoverflow.com/questions/17885855/use-dynamic-variable-string-as-regex-pattern-in-javascript
+ */
+export const escapeRegExp = (stringToGoIntoTheRegex: string): string => {
+ return stringToGoIntoTheRegex.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/fixedDecimalValue.ts b/packages/ui/src/hooks/useNumberInput/utils/fixedDecimalValue.ts
new file mode 100644
index 000000000..69ca613b3
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/fixedDecimalValue.ts
@@ -0,0 +1,39 @@
+export const fixedDecimalValue = (
+ value: string,
+ decimalSeparator: string,
+ fixedDecimalLength?: number,
+): string => {
+ if (fixedDecimalLength !== undefined && value.length > 1) {
+ if (fixedDecimalLength === 0) {
+ return value.replace(decimalSeparator, "");
+ }
+
+ if (value.includes(decimalSeparator)) {
+ const [int, decimals] = value.split(decimalSeparator);
+
+ if (decimals.length === fixedDecimalLength) {
+ return value;
+ }
+
+ if (decimals.length > fixedDecimalLength) {
+ return `${int}${decimalSeparator}${decimals.slice(
+ 0,
+ fixedDecimalLength,
+ )}`;
+ }
+ }
+
+ const reg =
+ value.length > fixedDecimalLength
+ ? new RegExp(`(\\d+)(\\d{${fixedDecimalLength}})`)
+ : new RegExp(`(\\d)(\\d+)`);
+
+ const match = value.match(reg);
+ if (match) {
+ const [, int, decimals] = match;
+ return `${int}${decimalSeparator}${decimals}`;
+ }
+ }
+
+ return value;
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/formatValue.ts b/packages/ui/src/hooks/useNumberInput/utils/formatValue.ts
new file mode 100644
index 000000000..eb1f36968
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/formatValue.ts
@@ -0,0 +1,249 @@
+import { type IntlConfig } from "../types.js";
+import { escapeRegExp } from "./escapeRegExp.js";
+import { getSuffix } from "./getSuffix.js";
+
+export type FormatValueOptions = {
+ /**
+ * Value to format
+ */
+ value: string | undefined;
+
+ /**
+ * Decimal separator
+ *
+ * Default = '.'
+ */
+ decimalSeparator?: string | undefined;
+
+ /**
+ * Group separator
+ *
+ * Default = ','
+ */
+ groupSeparator?: string | undefined;
+
+ /**
+ * Turn off separators
+ *
+ * This will override Group separators
+ *
+ * Default = false
+ */
+ disableGroupSeparators?: boolean | undefined;
+
+ /**
+ * Intl locale currency config
+ */
+ intlConfig?: IntlConfig | undefined;
+
+ /**
+ * Specify decimal scale for padding/trimming
+ *
+ * Eg. 1.5 -> 1.50 or 1.234 -> 1.23
+ */
+ decimalScale?: number | undefined;
+
+ /**
+ * Prefix
+ */
+ prefix?: string | undefined;
+
+ /**
+ * Suffix
+ */
+ suffix?: string | undefined;
+};
+
+/**
+ * Format value with decimal separator, group separator and prefix
+ */
+export const formatValue = (options: FormatValueOptions): string => {
+ const {
+ value: _value,
+ decimalSeparator,
+ intlConfig,
+ decimalScale,
+ prefix = "",
+ suffix = "",
+ } = options;
+
+ if (_value === "" || _value === undefined) {
+ return "";
+ }
+
+ if (_value === "-") {
+ return "-";
+ }
+
+ const isNegative = new RegExp(
+ `^\\d?-${prefix ? `${escapeRegExp(prefix)}?` : ""}\\d`,
+ ).test(_value);
+
+ let value =
+ decimalSeparator !== "."
+ ? replaceDecimalSeparator(_value, decimalSeparator, isNegative)
+ : _value;
+
+ if (
+ decimalSeparator &&
+ decimalSeparator !== "-" &&
+ value.startsWith(decimalSeparator)
+ ) {
+ value = "0" + value;
+ }
+
+ const defaultNumberFormatOptions = {
+ minimumFractionDigits: decimalScale || 0,
+ maximumFractionDigits: 20,
+ };
+
+ const numberFormatter = intlConfig
+ ? new Intl.NumberFormat(
+ intlConfig.locale,
+ intlConfig.currency
+ ? {
+ ...defaultNumberFormatOptions,
+ style: "currency",
+ currency: intlConfig.currency,
+ }
+ : defaultNumberFormatOptions,
+ )
+ : new Intl.NumberFormat(undefined, defaultNumberFormatOptions);
+
+ const parts = numberFormatter.formatToParts(Number(value));
+
+ let formatted = replaceParts(parts, options);
+
+ // Does intl formatting add a suffix?
+ const intlSuffix = getSuffix(formatted, { ...options });
+
+ // Include decimal separator if user input ends with decimal separator
+ const includeDecimalSeparator =
+ _value.slice(-1) === decimalSeparator ? decimalSeparator : "";
+
+ const [, decimals] = value.match(RegExp("\\d+\\.(\\d+)")) || [];
+
+ // Keep original decimal padding if no decimalScale
+ if (decimalScale === undefined && decimals && decimalSeparator) {
+ if (formatted.includes(decimalSeparator)) {
+ formatted = formatted.replace(
+ RegExp(`(\\d+)(${escapeRegExp(decimalSeparator)})(\\d+)`, "g"),
+ `$1$2${decimals}`,
+ );
+ } else {
+ if (intlSuffix && !suffix) {
+ formatted = formatted.replace(
+ intlSuffix,
+ `${decimalSeparator}${decimals}${intlSuffix}`,
+ );
+ } else {
+ formatted = `${formatted}${decimalSeparator}${decimals}`;
+ }
+ }
+ }
+
+ if (suffix && includeDecimalSeparator) {
+ return `${formatted}${includeDecimalSeparator}${suffix}`;
+ }
+
+ if (intlSuffix && includeDecimalSeparator) {
+ return formatted.replace(
+ intlSuffix,
+ `${includeDecimalSeparator}${intlSuffix}`,
+ );
+ }
+
+ if (intlSuffix && suffix) {
+ return formatted.replace(intlSuffix, `${includeDecimalSeparator}${suffix}`);
+ }
+
+ return [formatted, includeDecimalSeparator, suffix].join("");
+};
+
+/**
+ * Before converting to Number, decimal separator has to be .
+ */
+const replaceDecimalSeparator = (
+ value: string,
+ decimalSeparator: FormatValueOptions["decimalSeparator"],
+ isNegative: boolean,
+): string => {
+ let newValue = value;
+ if (decimalSeparator && decimalSeparator !== ".") {
+ newValue = newValue.replace(
+ RegExp(escapeRegExp(decimalSeparator), "g"),
+ ".",
+ );
+ if (isNegative && decimalSeparator === "-") {
+ newValue = `-${newValue.slice(1)}`;
+ }
+ }
+ return newValue;
+};
+
+const replaceParts = (
+ parts: Intl.NumberFormatPart[],
+ {
+ prefix,
+ groupSeparator,
+ decimalSeparator,
+ decimalScale,
+ disableGroupSeparators = false,
+ }: Pick<
+ FormatValueOptions,
+ | "prefix"
+ | "groupSeparator"
+ | "decimalSeparator"
+ | "decimalScale"
+ | "disableGroupSeparators"
+ >,
+): string => {
+ return parts
+ .reduce(
+ (prev, { type, value }, i) => {
+ if (i === 0 && prefix) {
+ if (type === "minusSign") {
+ return [value, prefix];
+ }
+
+ if (type === "currency") {
+ return [...prev, prefix];
+ }
+
+ return [prefix, value];
+ }
+
+ if (type === "currency") {
+ return prefix ? prev : [...prev, value];
+ }
+
+ if (type === "group") {
+ return !disableGroupSeparators
+ ? [...prev, groupSeparator !== undefined ? groupSeparator : value]
+ : prev;
+ }
+
+ if (type === "decimal") {
+ if (decimalScale !== undefined && decimalScale === 0) {
+ return prev;
+ }
+
+ return [
+ ...prev,
+ decimalSeparator !== undefined ? decimalSeparator : value,
+ ];
+ }
+
+ if (type === "fraction") {
+ return [
+ ...prev,
+ decimalScale !== undefined ? value.slice(0, decimalScale) : value,
+ ];
+ }
+
+ return [...prev, value];
+ },
+ [""],
+ )
+ .join("");
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/getLocaleConfig.ts b/packages/ui/src/hooks/useNumberInput/utils/getLocaleConfig.ts
new file mode 100644
index 000000000..ffa0c6769
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/getLocaleConfig.ts
@@ -0,0 +1,50 @@
+import { type IntlConfig } from "../types.js";
+
+type LocaleConfig = {
+ currencySymbol: string;
+ groupSeparator: string;
+ decimalSeparator: string;
+ prefix: string;
+ suffix: string;
+};
+
+const defaultConfig: LocaleConfig = {
+ currencySymbol: "",
+ groupSeparator: "",
+ decimalSeparator: "",
+ prefix: "",
+ suffix: "",
+};
+
+/**
+ * Get locale config from input or default
+ */
+export const getLocaleConfig = (intlConfig?: IntlConfig): LocaleConfig => {
+ const { locale, currency } = intlConfig || {};
+ const numberFormatter = locale
+ ? new Intl.NumberFormat(
+ locale,
+ currency ? { currency, style: "currency" } : undefined,
+ )
+ : new Intl.NumberFormat();
+
+ return numberFormatter
+ .formatToParts(1000.1)
+ .reduce((prev, curr, i): LocaleConfig => {
+ if (curr.type === "currency") {
+ if (i === 0) {
+ return { ...prev, currencySymbol: curr.value, prefix: curr.value };
+ } else {
+ return { ...prev, currencySymbol: curr.value, suffix: curr.value };
+ }
+ }
+ if (curr.type === "group") {
+ return { ...prev, groupSeparator: curr.value };
+ }
+ if (curr.type === "decimal") {
+ return { ...prev, decimalSeparator: curr.value };
+ }
+
+ return prev;
+ }, defaultConfig);
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/getSuffix.ts b/packages/ui/src/hooks/useNumberInput/utils/getSuffix.ts
new file mode 100644
index 000000000..dd1c7c158
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/getSuffix.ts
@@ -0,0 +1,18 @@
+import { escapeRegExp } from "./escapeRegExp.js";
+type Options = {
+ decimalSeparator?: string | undefined;
+ groupSeparator?: string | undefined;
+};
+
+export const getSuffix = (
+ value: string,
+ { groupSeparator = ",", decimalSeparator = "." }: Options,
+): string | undefined => {
+ const suffixReg = new RegExp(
+ `\\d([^${escapeRegExp(groupSeparator)}${escapeRegExp(
+ decimalSeparator,
+ )}0-9]+)`,
+ );
+ const suffixMatch = value.match(suffixReg);
+ return suffixMatch ? suffixMatch[1] : undefined;
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/index.ts b/packages/ui/src/hooks/useNumberInput/utils/index.ts
new file mode 100644
index 000000000..a8fc1bb92
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/index.ts
@@ -0,0 +1,8 @@
+export * from "./cleanValue.js";
+export * from "./fixedDecimalValue.js";
+export * from "./formatValue.js";
+export * from "./getLocaleConfig.js";
+export * from "./getSuffix.js";
+export * from "./isNumber.js";
+export * from "./padTrimValue.js";
+export * from "./repositionCursor.js";
diff --git a/packages/ui/src/hooks/useNumberInput/utils/isNumber.ts b/packages/ui/src/hooks/useNumberInput/utils/isNumber.ts
new file mode 100644
index 000000000..aa5d9c1bb
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/isNumber.ts
@@ -0,0 +1,2 @@
+export const isNumber = (input: string): boolean =>
+ RegExp(/\d/, "gi").test(input);
diff --git a/packages/ui/src/hooks/useNumberInput/utils/padTrimValue.ts b/packages/ui/src/hooks/useNumberInput/utils/padTrimValue.ts
new file mode 100644
index 000000000..b0808c215
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/padTrimValue.ts
@@ -0,0 +1,31 @@
+export const padTrimValue = (
+ value: string,
+ decimalSeparator = ".",
+ decimalScale?: number,
+): string => {
+ if (decimalScale === undefined || value === "" || value === undefined) {
+ return value;
+ }
+
+ if (!value.match(/\d/g)) {
+ return "";
+ }
+
+ const [int, decimals] = value.split(decimalSeparator);
+
+ if (decimalScale === 0) {
+ return int;
+ }
+
+ let newValue = decimals || "";
+
+ if (newValue.length < decimalScale) {
+ while (newValue.length < decimalScale) {
+ newValue += "0";
+ }
+ } else {
+ newValue = newValue.slice(0, decimalScale);
+ }
+
+ return `${int}${decimalSeparator}${newValue}`;
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/parseAbbrValue.ts b/packages/ui/src/hooks/useNumberInput/utils/parseAbbrValue.ts
new file mode 100644
index 000000000..1c8ff954b
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/parseAbbrValue.ts
@@ -0,0 +1,53 @@
+import { escapeRegExp } from "./escapeRegExp.js";
+
+/**
+ * Abbreviate number eg. 1000 = 1k
+ *
+ * Source: https://stackoverflow.com/a/9345181
+ */
+export const abbrValue = (
+ value: number,
+ decimalSeparator = ".",
+ _decimalPlaces = 10,
+): string => {
+ if (value > 999) {
+ let valueLength = ("" + value).length;
+ const p = Math.pow;
+ const d = p(10, _decimalPlaces);
+ valueLength -= valueLength % 3;
+
+ const abbrValue =
+ Math.round((value * d) / p(10, valueLength)) / d +
+ " kMGTPE"[valueLength / 3];
+ return abbrValue.replace(".", decimalSeparator);
+ }
+
+ return String(value);
+};
+
+type AbbrMap = { [key: string]: number };
+
+const abbrMap: AbbrMap = { k: 1000, m: 1000000, b: 1000000000 };
+
+/**
+ * Parse a value with abbreviation e.g 1k = 1000
+ */
+export const parseAbbrValue = (
+ value: string,
+ decimalSeparator = ".",
+): number | undefined => {
+ const reg = new RegExp(
+ `(\\d+(${escapeRegExp(decimalSeparator)}\\d*)?)([kmb])$`,
+ "i",
+ );
+ const match = value.match(reg);
+
+ if (match) {
+ const [, digits, , abbr] = match;
+ const multiplier = abbrMap[abbr.toLowerCase()];
+
+ return Number(digits.replace(decimalSeparator, ".")) * multiplier;
+ }
+
+ return undefined;
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/removeInvalidChars.ts b/packages/ui/src/hooks/useNumberInput/utils/removeInvalidChars.ts
new file mode 100644
index 000000000..5a39bd3ab
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/removeInvalidChars.ts
@@ -0,0 +1,13 @@
+import { escapeRegExp } from "./escapeRegExp.js";
+
+/**
+ * Remove invalid characters
+ */
+export const removeInvalidChars = (
+ value: string,
+ validChars: ReadonlyArray,
+): string => {
+ const chars = escapeRegExp(validChars.join(""));
+ const reg = new RegExp(`[^\\d${chars}]`, "gi");
+ return value.replace(reg, "");
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/removeSeparators.ts b/packages/ui/src/hooks/useNumberInput/utils/removeSeparators.ts
new file mode 100644
index 000000000..e7f08e29e
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/removeSeparators.ts
@@ -0,0 +1,9 @@
+import { escapeRegExp } from "./escapeRegExp.js";
+
+/**
+ * Remove group separator from value eg. 1,000 > 1000
+ */
+export const removeSeparators = (value: string, separator = ","): string => {
+ const reg = new RegExp(escapeRegExp(separator), "g");
+ return value.replace(reg, "");
+};
diff --git a/packages/ui/src/hooks/useNumberInput/utils/repositionCursor.ts b/packages/ui/src/hooks/useNumberInput/utils/repositionCursor.ts
new file mode 100644
index 000000000..f1028be7d
--- /dev/null
+++ b/packages/ui/src/hooks/useNumberInput/utils/repositionCursor.ts
@@ -0,0 +1,50 @@
+type RepositionCursorProps = {
+ selectionStart?: number | null | undefined;
+ value: string;
+ lastKeyStroke: string | null | undefined;
+ stateValue?: string | undefined;
+ groupSeparator?: string | undefined;
+};
+
+/**
+ * Based on the last key stroke and the cursor position, update the value
+ * and reposition the cursor to the right place
+ */
+export const repositionCursor = ({
+ selectionStart,
+ value,
+ lastKeyStroke,
+ stateValue,
+ groupSeparator,
+}: RepositionCursorProps): {
+ modifiedValue: string;
+ cursorPosition: number | null | undefined;
+} => {
+ let cursorPosition = selectionStart;
+ let modifiedValue = value;
+ if (stateValue && cursorPosition) {
+ const splitValue = value.split("");
+ // if cursor is to right of groupSeparator and backspace pressed, delete the character to the
+ // left of the separator and reposition the cursor
+ if (
+ lastKeyStroke === "Backspace" &&
+ stateValue[cursorPosition] === groupSeparator
+ ) {
+ splitValue.splice(cursorPosition - 1, 1);
+ cursorPosition -= 1;
+ }
+ // if cursor is to left of groupSeparator and delete pressed, delete the character to the right
+ // of the separator and reposition the cursor
+ if (
+ lastKeyStroke === "Delete" &&
+ stateValue[cursorPosition] === groupSeparator
+ ) {
+ splitValue.splice(cursorPosition, 1);
+ cursorPosition += 1;
+ }
+ modifiedValue = splitValue.join("");
+ return { modifiedValue, cursorPosition };
+ }
+
+ return { modifiedValue, cursorPosition: selectionStart };
+};