From ca9d283f5357a85237b033c83c9951f8b5f99e8f Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 4 Sep 2026 23:59:36 +0200 Subject: [PATCH] Add locale-aware date formatter --- README.md | 26 +++++++++++ package.json | 6 ++- src/date.ts | 64 ++++++++++++++++++++++++++++ tests/date-formatter.browser.test.ts | 19 +++++++++ tests/date-formatter.test.ts | 37 ++++++++++++++++ vitest.config.mts | 6 ++- 6 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/date.ts create mode 100644 tests/date-formatter.browser.test.ts create mode 100644 tests/date-formatter.test.ts diff --git a/README.md b/README.md index b4957bc..368b2f7 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,32 @@ euro.format("-12345,6"); Fraction digit limits must be non-negative integers, and the minimum must not exceed the maximum. +## Date formatter + +Import `createDateFormatter` from `rifm/date`. It uses the locale's numeric date field order and separators while preserving incomplete input: + +```tsx +import { createDateFormatter } from "rifm/date"; + +const date = createDateFormatter({ + locales: "en-US", + year: "long", +}); + +const rifm = useRifm({ + value, + onChange: setValue, + ...date, + mask: true, +}); +``` + +The `year` option accepts `"short"` for two digits or `"long"` for four digits and defaults to `"long"`. For example, `120826` becomes `12/08/26` with a short year, while `12082026` becomes `12/08/2026` with a long year. + +Leave out `mask` for insertion-style formatting. Set `mask: true` for replacement-style editing of occupied date positions. The formatter is structural and does not validate whether a completed value is a real calendar date. + +As with other RIFM inputs, use `type="text"` and `inputMode="numeric"`, not `type="date"`. + ## 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 f2b2507..1ff5d05 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,14 @@ "./number": { "types": "./dist/number.d.ts", "import": "./dist/number.js" + }, + "./date": { + "types": "./dist/date.d.ts", + "import": "./dist/date.js" } }, "scripts": { - "build": "tsdown src/index.ts src/number.ts --format esm --target es2022 --platform neutral --dts --clean", + "build": "tsdown src/index.ts src/number.ts src/date.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/date.ts b/src/date.ts new file mode 100644 index 0000000..2fda34e --- /dev/null +++ b/src/date.ts @@ -0,0 +1,64 @@ +export interface DateFormatterOptions { + locales?: string | string[]; + year?: "short" | "long"; +} + +export interface DateFormatter { + format: (value: string) => string; + accept: RegExp; +} + +type DatePart = "day" | "month" | "year"; + +/** Creates locale-aware `format` and `accept` props for RIFM. */ +export const createDateFormatter = (options: DateFormatterOptions = {}): DateFormatter => { + const { locales, year = "long" } = options; + const yearLength = year === "short" ? 2 : 4; + const parts = new Intl.DateTimeFormat(locales, { + calendar: "gregory", + numberingSystem: "latn", + day: "2-digit", + month: "2-digit", + year: year === "short" ? "2-digit" : "numeric", + timeZone: "UTC", + }).formatToParts(new Date(Date.UTC(2006, 10, 22))); + + const fields: DatePart[] = []; + const separators: string[] = []; + let literal = ""; + + for (const part of parts) { + if (part.type === "day" || part.type === "month" || part.type === "year") { + if (fields.length > 0) separators.push(literal); + fields.push(part.type); + literal = ""; + } else if (fields.length > 0) { + literal += part.value; + } + } + + const lengths: Record = { day: 2, month: 2, year: yearLength }; + const maximumLength = 4 + yearLength; + const accept = /\d/g; + + const format = (value: string): string => { + const digits = (value.match(accept) ?? []).join("").slice(0, maximumLength); + const segments: string[] = []; + let offset = 0; + + for (const field of fields) { + const segment = digits.slice(offset, offset + lengths[field]); + if (segment === "") break; + segments.push(segment); + offset += lengths[field]; + } + + return segments.reduce( + (result, segment, index) => + index === 0 ? segment : `${result}${separators[index - 1]}${segment}`, + "", + ); + }; + + return { format, accept }; +}; diff --git a/tests/date-formatter.browser.test.ts b/tests/date-formatter.browser.test.ts new file mode 100644 index 0000000..6bd8c16 --- /dev/null +++ b/tests/date-formatter.browser.test.ts @@ -0,0 +1,19 @@ +import { afterEach, expect, test } from "vitest"; +import { createDateFormatter } from "../src/date"; +import { BrowserExec, createBrowserExec } from "./utils/browser-exec"; + +let exec: BrowserExec | null = null; + +afterEach(() => { + exec?.cleanup(); + exec = null; +}); + +test("date formatter supports replacement-style mask editing", async () => { + const formatter = createDateFormatter({ locales: "en-US", year: "long" }); + exec = createBrowserExec({ ...formatter, mask: true }); + + expect(await exec({ type: "PUT_SYMBOL", payload: "12082026" })).toBe("12/08/2026|"); + expect(await exec({ type: "MOVE_CARET", payload: -4 })).toBe("12/08/|2026"); + expect(await exec({ type: "PUT_SYMBOL", payload: "1" })).toBe("12/08/1|026"); +}); diff --git a/tests/date-formatter.test.ts b/tests/date-formatter.test.ts new file mode 100644 index 0000000..a8a8e29 --- /dev/null +++ b/tests/date-formatter.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { createDateFormatter } from "../src/date"; + +describe("date formatter", () => { + test("uses locale field order and separators", () => { + const us = createDateFormatter({ locales: "en-US" }); + const german = createDateFormatter({ locales: "de-DE" }); + const japanese = createDateFormatter({ locales: "ja-JP" }); + + expect(us.format("12082026")).toBe("12/08/2026"); + expect(german.format("12082026")).toBe("12.08.2026"); + expect(japanese.format("20261208")).toBe("2026/12/08"); + }); + + test("preserves partial input and limits its length", () => { + const formatter = createDateFormatter({ locales: "en-US" }); + + expect(formatter.format("1")).toBe("1"); + expect(formatter.format("120")).toBe("12/0"); + expect(formatter.format("12 / 08 / 202699")).toBe("12/08/2026"); + expect(formatter.format(formatter.format("12082026"))).toBe("12/08/2026"); + }); + + test("supports short years", () => { + const formatter = createDateFormatter({ locales: "en-GB", year: "short" }); + + expect(formatter.format("120826")).toBe("12/08/26"); + expect(formatter.format("12082026")).toBe("12/08/20"); + }); + + test("returns an object which can be spread into Rifm options", () => { + const formatter = createDateFormatter(); + + expect(Object.keys(formatter)).toEqual(["format", "accept"]); + expect("12/08/2026".match(formatter.accept)?.join("")).toBe("12082026"); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index a9cf10e..8ca166e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -18,7 +18,11 @@ export default defineConfig({ test: { name: "node", environment: "node", - include: ["tests/formatters.test.ts", "tests/test-layout-warn.test.tsx"], + include: [ + "tests/formatters.test.ts", + "tests/*-formatter.test.ts", + "tests/test-layout-warn.test.tsx", + ], }, }, {