Browse Source

fix(question-number): normalize Persian/Arabic digits and use text inputMode numeric

master
mortezaei 2 days ago
parent
commit
7df253acd7
  1. 80
      src/components/Componentes/question-number.test.tsx
  2. 83
      src/components/Componentes/question-number.tsx

80
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="family_background" questions={[mockQuestion]}>
<QuestionNumber question={mockQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="family_background" questions={[mockQuestion]}>
<QuestionNumber question={mockQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
const input = screen.getByPlaceholderText("e.g. 3") as HTMLInputElement;
fireEvent.change(input, { target: { value: "5" } });
expect(input.value).toBe("5");
});
});

83
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)
) {
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({
>
<QuestionTitle question={question} />
<Input
type="number"
type="text"
inputMode="numeric"
required={question.required && !disabled}
disabled={disabled || Boolean(derivedFromQuestion)}
min={min || undefined}
max={max || undefined}
placeholder={question.extras.placeHolder}
value={inputValue}
onChange={(event) => {
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);
if (!NUMBER_INPUT_PATTERN.test(cleaned)) {
return;
}
const parsed = parseFloat(cleaned);
setAnswerValue(
question,
Number.isNaN(parsed) ? nextValue : parsed,
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]",

Loading…
Cancel
Save