diff --git a/README.md b/README.md index bbdddcf..b4957bc 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,51 @@ Both `useRifm(options)` and `` accept the following options. Pass both properties to the underlying input. +## Number formatter + +Import `createNumberFormatter` from `rifm/number`. With no options, it uses the runtime's default locale, groups thousands, accepts positive numbers, and preserves any number of fractional digits: + +```tsx +import { createNumberFormatter } from "rifm/number"; + +const number = createNumberFormatter(); +const rifm = useRifm({ + value, + onChange: setValue, + ...number, +}); +``` + +The returned `{ format, accept }` object can be spread into either `useRifm` or ``. Formatting is string-based, so editable states such as `1.` and `1.20` are preserved and large integers do not lose precision. + +### Number formatter options + +The examples below use the `en-US` locale unless another locale is specified. + +| Option | Default | Description | Example | +| ----------------------- | --------------- | ---------------------------------------------------------- | -------------------------------------------------- | +| `locales` | Runtime default | Locale or locale fallback list used by `Intl.NumberFormat` | `{ locales: "de-DE" }`: `12345,6` → `12.345,6` | +| `useGrouping` | `true` | Enables locale-specific integer grouping | `{ useGrouping: false }`: `1234.5` → `1234.5` | +| `allowNegative` | `false` | Preserves a minus sign when present | `{ allowNegative: true }`: `-1234.5` → `-1,234.5` | +| `minimumFractionDigits` | `0` | Pads the fractional part with zeroes | `{ minimumFractionDigits: 2 }`: `12.5` → `12.50` | +| `maximumFractionDigits` | Unlimited | Truncates the fractional part to this length | `{ maximumFractionDigits: 2 }`: `12.345` → `12.34` | + +Options can be combined: + +```ts +const euro = createNumberFormatter({ + locales: "de-DE", + allowNegative: true, + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +euro.format("-12345,6"); +// "-12.345,60" +``` + +Fraction digit limits must be non-negative integers, and the minimum must not exceed the maximum. + ## Accepted characters and caret behavior RIFM restores the caret by tracking the characters matched by `accept`. The formatter may insert, remove, or move separators, but it should preserve the order of those accepted characters. diff --git a/package.json b/package.json index 04edfbd..255ab41 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./number": { + "types": "./dist/number.d.ts", + "import": "./dist/number.js" } }, "scripts": { - "build": "tsdown src/index.ts --format esm --target es2022 --platform neutral --dts --clean", + "build": "tsdown src/index.ts src/number.ts --format esm --target es2022 --platform neutral --dts --clean", "test:unit": "vitest run --project node", "test:browser": "vitest run --project browser", "test": "pnpm run test:ts && pnpm run test:unit && pnpm run test:browser", diff --git a/src/number.ts b/src/number.ts new file mode 100644 index 0000000..ba5059c --- /dev/null +++ b/src/number.ts @@ -0,0 +1,63 @@ +export interface NumberFormatterOptions { + locales?: string | string[]; + allowNegative?: boolean; + useGrouping?: boolean; + maximumFractionDigits?: number; + minimumFractionDigits?: number; +} + +export interface NumberFormatter { + format: (value: string) => string; + accept: RegExp; +} + +/** Creates locale-aware `format` and `accept` props for RIFM. */ +export const createNumberFormatter = (options: NumberFormatterOptions = {}): NumberFormatter => { + const { + locales, + allowNegative = false, + useGrouping = true, + maximumFractionDigits, + minimumFractionDigits = 0, + } = options; + + // Latin digits make the result compatible with RIFM's digit-based caret tracking. + const latin = { numberingSystem: "latn" }; + const integerFormatter = new Intl.NumberFormat(locales, { + ...latin, + useGrouping, + maximumFractionDigits: 0, + }); + const partFormatter = new Intl.NumberFormat(locales, { + ...latin, + useGrouping: true, + minimumFractionDigits: 1, + }); + const decimalSeparator = + partFormatter.formatToParts(1.1).find((part) => part.type === "decimal")?.value ?? "."; + const acceptsFraction = maximumFractionDigits !== 0; + const accept = new RegExp( + `[\\d${acceptsFraction ? decimalSeparator : ""}${allowNegative ? "\\-" : ""}]`, + "g", + ); + + const format = (value: string): string => { + const decimalIndex = acceptsFraction ? value.indexOf(decimalSeparator) : -1; + const integerSource = decimalIndex < 0 ? value : value.slice(0, decimalIndex); + const fractionSource = decimalIndex < 0 ? "" : value.slice(decimalIndex + 1); + const negative = allowNegative && value.includes("-"); + let integer = integerSource.replace(/\D/g, "").replace(/^0+(?=\d)/, ""); + let fraction = fractionSource.replace(/\D/g, ""); + + if (maximumFractionDigits != null) fraction = fraction.slice(0, maximumFractionDigits); + if (integer === "" && decimalIndex >= 0) integer = "0"; + if (integer === "") return negative ? "-" : ""; + + fraction = fraction.padEnd(minimumFractionDigits, "0"); + const grouped = integerFormatter.format(BigInt(`${negative ? "-" : ""}${integer}`)); + const decimal = decimalIndex >= 0 || minimumFractionDigits > 0; + return `${grouped}${decimal ? `${decimalSeparator}${fraction}` : ""}`; + }; + + return { format, accept }; +}; diff --git a/tests/number-formatter.test.ts b/tests/number-formatter.test.ts new file mode 100644 index 0000000..64c53b3 --- /dev/null +++ b/tests/number-formatter.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; +import { createNumberFormatter } from "../src/number"; + +describe("number formatter", () => { + test("groups integers and preserves editable fractional values", () => { + const formatter = createNumberFormatter({ maximumFractionDigits: 2 }); + + expect(formatter.format("1234567.20")).toBe("1,234,567.20"); + expect(formatter.format("1,234,567.")).toBe("1,234,567."); + expect(formatter.format(".5")).toBe("0.5"); + expect(formatter.format("123.456")).toBe("123.45"); + expect(formatter.format("900719925474099312345")).toBe("900,719,925,474,099,312,345"); + }); + + test("supports locale separators", () => { + const formatter = createNumberFormatter({ + locales: "de-DE", + maximumFractionDigits: 2, + }); + + const formatted = formatter.format("12345,6"); + expect(formatted).toBe("12.345,6"); + expect("12.345,6".match(formatter.accept)?.join("")).toBe("12345,6"); + }); + + test("supports signs, fixed precision, custom grouping, and disabled grouping", () => { + expect( + createNumberFormatter({ + locales: "en-IN", + allowNegative: true, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format("-$12345678.9"), + ).toBe("-1,23,45,678.90"); + + expect( + createNumberFormatter({ useGrouping: false, maximumFractionDigits: 0 }).format("12,345"), + ).toBe("12345"); + }); + + test("returns an object which can be spread into Rifm options", () => { + expect(Object.keys(createNumberFormatter())).toEqual(["format", "accept"]); + }); +}); diff --git a/tests/rifm-format.browser.test.tsx b/tests/rifm-format.browser.test.tsx index 553edf2..09bc198 100644 --- a/tests/rifm-format.browser.test.tsx +++ b/tests/rifm-format.browser.test.tsx @@ -1,5 +1,6 @@ import { afterEach, expect, it, test } from "vitest"; import { formatFixedPointNumber, formatFloatingPointNumber, formatPhone } from "./format"; +import { createNumberFormatter } from "../src/number"; import { BrowserExec, createBrowserExec } from "./utils/browser-exec"; let exec: BrowserExec | null = null; @@ -34,6 +35,22 @@ test("format works with real browser input", async () => { expect(await exec({ type: "PUT_SYMBOL", payload: "x" })).toBe("1|2’345"); }); +test("keeps the caret after a negative sign", async () => { + exec = createBrowserExec({ + ...createNumberFormatter({ allowNegative: true, maximumFractionDigits: 0 }), + }); + + expect(await exec({ type: "PUT_SYMBOL", payload: "-" })).toBe("-|"); + + exec.cleanup(); + exec = createBrowserExec({ + ...createNumberFormatter({ allowNegative: true, maximumFractionDigits: 0 }), + initialValue: "123", + }); + + expect(await exec({ type: "PUT_SYMBOL", payload: "-" })).toBe("-|123"); +}); + test("format with custom accept works with real browser input", async () => { exec = createBrowserExec({ accept: /[\d.]/gi, diff --git a/tsconfig.json b/tsconfig.json index d9081a8..d748755 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,10 +9,6 @@ "strict": true, "noEmit": true, "skipLibCheck": true, - "baseUrl": ".", - "paths": { - "rifm": ["./src"] - }, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true,