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 }; +};