From 7df253acd75613cba3206be6d29d84cacd708873 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 16:04:10 +0330 Subject: [PATCH] fix(question-number): normalize Persian/Arabic digits and use text inputMode numeric --- .../Componentes/question-number.test.tsx | 80 +++++++++++++++++ .../Componentes/question-number.tsx | 89 +++++++++++++------ 2 files changed, 143 insertions(+), 26 deletions(-) create mode 100644 src/components/Componentes/question-number.test.tsx diff --git a/src/components/Componentes/question-number.test.tsx b/src/components/Componentes/question-number.test.tsx new file mode 100644 index 0000000..c9f56e6 --- /dev/null +++ b/src/components/Componentes/question-number.test.tsx @@ -0,0 +1,80 @@ +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { describe, expect, it, afterEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import QuestionNumber, { normalizeNumberString } from "./question-number"; +import { QuestionAnswersProvider } from "./question-answer-storage"; +import type { QuestionField } from "@/lib/schema-adapter"; + +describe("normalizeNumberString", () => { + it("should convert Persian digits to English digits", () => { + expect(normalizeNumberString("۱۲۳۴۵۶۷۸۹۰")).toBe("1234567890"); + expect(normalizeNumberString("۵")).toBe("5"); + expect(normalizeNumberString("۳٫۵")).toBe("3.5"); + }); + + it("should convert Arabic digits to English digits", () => { + expect(normalizeNumberString("١٢٣٤٥٦٧٨٩٠")).toBe("1234567890"); + expect(normalizeNumberString("٤")).toBe("4"); + }); + + it("should preserve English digits", () => { + expect(normalizeNumberString("12345")).toBe("12345"); + }); +}); + +describe("QuestionNumber Component", () => { + afterEach(() => { + cleanup(); + }); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const mockQuestion: QuestionField = { + id: "q1_number_of_siblings", + title: "Number of Siblings", + type: "number", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "e.g. 3", range: [0, 20] }, + options: [], + ui_config: {}, + }; + + it("should allow entering Persian digits without clearing input", () => { + render( + + + + + , + ); + + const input = screen.getByPlaceholderText("e.g. 3") as HTMLInputElement; + fireEvent.change(input, { target: { value: "۴" } }); + + expect(input.value).toBe("4"); + }); + + it("should allow entering English digits", () => { + render( + + + + + , + ); + + const input = screen.getByPlaceholderText("e.g. 3") as HTMLInputElement; + fireEvent.change(input, { target: { value: "5" } }); + + expect(input.value).toBe("5"); + }); +}); diff --git a/src/components/Componentes/question-number.tsx b/src/components/Componentes/question-number.tsx index 5baf1bf..ddb07c6 100644 --- a/src/components/Componentes/question-number.tsx +++ b/src/components/Componentes/question-number.tsx @@ -18,6 +18,33 @@ type QuestionNumberProps = { derivedFromQuestionIndex?: number; }; +const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; +const ARABIC_DIGITS = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"]; + +export function normalizeNumberString(val: string): string { + if (!val) return ""; + let result = ""; + for (let i = 0; i < val.length; i++) { + const char = val[i]; + const pIdx = PERSIAN_DIGITS.indexOf(char); + if (pIdx !== -1) { + result += String(pIdx); + continue; + } + const aIdx = ARABIC_DIGITS.indexOf(char); + if (aIdx !== -1) { + result += String(aIdx); + continue; + } + if (char === "٫") { + result += "."; + continue; + } + result += char; + } + return result; +} + const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/; export default function QuestionNumber({ @@ -51,12 +78,17 @@ export default function QuestionNumber({ ]); useEffect(() => { - if ( - typeof value === "string" && - value.length > 0 && - !NUMBER_INPUT_PATTERN.test(value) - ) { - setAnswerValue(question, null); + if (typeof value === "string" && value.length > 0) { + const normalized = normalizeNumberString(value); + if (!NUMBER_INPUT_PATTERN.test(normalized)) { + setAnswerValue(question, null); + } else if (normalized !== value) { + const parsed = parseFloat(normalized); + setAnswerValue( + question, + Number.isNaN(parsed) ? normalized : parsed, + ); + } } }, [question, setAnswerValue, value]); @@ -66,7 +98,7 @@ export default function QuestionNumber({ typeof value === "number" ? value : typeof value === "string" - ? parseFloat(value) + ? parseFloat(normalizeNumberString(value)) : NaN; const isOutOfRange = useMemo(() => { if (Number.isNaN(numValue)) return false; @@ -76,8 +108,9 @@ export default function QuestionNumber({ }, [numValue, min, max]); const rawInputValue = value == null ? "" : String(value); - const inputValue = NUMBER_INPUT_PATTERN.test(rawInputValue) - ? rawInputValue + const normalizedRaw = normalizeNumberString(rawInputValue); + const inputValue = NUMBER_INPUT_PATTERN.test(normalizedRaw) + ? normalizedRaw : ""; const isMonthlyIncome = question.ui_config?.currency_enabled === true; @@ -174,22 +207,24 @@ export default function QuestionNumber({ value={localTextValue} onChange={(event) => { const nextValue = event.target.value; - const cleanValue = nextValue.replace(/,/g, ""); + const normalized = normalizeNumberString(nextValue); + const cleanValue = normalized.replace(/,/g, ""); if ( cleanValue !== "" && + cleanValue !== "-" && !NUMBER_INPUT_PATTERN.test(cleanValue) ) { return; } const formatted = formatNumberWithCommas(cleanValue); - const finalFormatted = nextValue.endsWith(".") + const finalFormatted = normalized.endsWith(".") ? `${formatted}.` : formatted; setLocalTextValue(finalFormatted); - if (cleanValue === "") { + if (cleanValue === "" || cleanValue === "-") { setAnswerValue(question, null); } else { const parsed = parseFloat(cleanValue); @@ -278,29 +313,31 @@ export default function QuestionNumber({ > { - const nextValue = event.target.value; - if (!NUMBER_INPUT_PATTERN.test(nextValue)) { - event.currentTarget.value = inputValue; + const raw = event.target.value; + const normalized = normalizeNumberString(raw); + const cleaned = normalized.replace(/[^0-9.-]/g, ""); + + if (cleaned === "" || cleaned === "-") { + setAnswerValue(question, null); return; } - if (nextValue === "") { - setAnswerValue(question, null); - } else { - const parsed = parseFloat(nextValue); - setAnswerValue( - question, - Number.isNaN(parsed) ? nextValue : parsed, - ); + if (!NUMBER_INPUT_PATTERN.test(cleaned)) { + return; } + + const parsed = parseFloat(cleaned); + setAnswerValue( + question, + Number.isNaN(parsed) ? cleaned : parsed, + ); }} className={[ "h-[54px] w-full rounded-[16px] border px-4.5 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",