Browse Source

feat: redesign country selection sheet with enhanced search, list items, and testing support

master
mortezaei 6 days ago
parent
commit
f9a01cef31
  1. 2
      .zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md
  2. 82
      src/components/Componentes/question-phone.test.tsx
  3. 140
      src/components/Componentes/question-phone.tsx

2
.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md

@ -84,4 +84,4 @@ body.section-overlay-open .app-shell { overflow-y: hidden; }
- `/en/questions-list` → کلیک سکشن: اسلاید از راست؛ `/fa/questions-list` → اسلاید از چپ.
- بستن با دکمه close (flush)، back مرورگر، و شبیه‌سازی هاردور بک — همه با انیمیشن خروج.
- refresh مستقیم روی `/en/questions-list/personal_identity` → صفحه کامل.
- حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن.
- حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن.له

82
src/components/Componentes/question-phone.test.tsx

@ -0,0 +1,82 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionPhone } from "./question-phone";
afterEach(() => {
cleanup();
});
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
dictionary: {
"Select country": "انتخاب کشور",
Close: "بستن",
Confirm: "تایید",
},
locale: "fa",
}),
}));
vi.mock("./question-answer-storage", () => ({
useQuestionAnswers: () => ({
getAnswerValue: () => null,
setAnswerValue: vi.fn(),
isLoading: false,
}),
}));
describe("QuestionPhone Component", () => {
const mockQuestion: QuestionField = {
id: "personal_contact_number",
title: "شماره تماس شخصی",
type: "phone",
extras: {
placeHolder: "+98 9123456789",
},
};
it("renders phone input and opens sheet on country code click", async () => {
render(<QuestionPhone question={mockQuestion} countryCode="+98" />);
const countryButton = screen.getByRole("button", { name: /\+98/i });
expect(countryButton).toBeInTheDocument();
fireEvent.click(countryButton);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(screen.getByText("انتخاب کشور")).toBeInTheDocument();
// Close button exists in header
const closeBtn = screen.getByLabelText("بستن");
expect(closeBtn).toBeInTheDocument();
// Search input is present with translated placeholder
const searchInput = screen.getByPlaceholderText("جستجو...");
expect(searchInput).toBeInTheDocument();
});
it("filters countries when searching and selects a country", async () => {
const { container } = render(
<QuestionPhone question={mockQuestion} countryCode="+44" />,
);
const triggerButton = container.querySelector("button")!;
fireEvent.click(triggerButton);
const searchInput = screen.getByPlaceholderText("جستجو...");
fireEvent.change(searchInput, { target: { value: "ایران" } });
// Option should be rendered
const iranOption = screen.getByRole("button", { name: /ایران/i });
expect(iranOption).toBeInTheDocument();
fireEvent.click(iranOption);
// After selection, dialog closes
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
});

140
src/components/Componentes/question-phone.tsx

@ -541,6 +541,22 @@ export function QuestionPhone({
const selectCountryTitle = t["Select country"] || question.title;
const searchPlaceholder =
locale === "fa"
? "جستجو..."
: locale === "ar"
? "بحث..."
: locale === "tr"
? "Ara..."
: "Search...";
const noResultsText =
locale === "fa"
? "موردی یافت نشد"
: locale === "ar"
? "لم يتم العثور على نتائج"
: "No options found";
return (
<div
data-question-answered={isAnswered ? "true" : "false"}
@ -639,18 +655,41 @@ export function QuestionPhone({
>
<section
className={[
"flex h-[68svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
"flex h-[82svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
<header className="shrink-0 px-5 pb-2 pt-1">
<h2 className="text-[17px] font-bold leading-6 text-[#181818]">
{/* Header with Title and Close Button */}
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
{selectCountryTitle}
</h2>
</header>
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label={t.Close || "Close"}
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="shrink-0 border-b border-[#EAECF0] px-5 pb-3">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
{/* Search Bar */}
<div className="px-5 pt-3.5 pb-2">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg
width="18"
height="18"
@ -679,20 +718,14 @@ export function QuestionPhone({
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={selectCountryTitle}
placeholder={
locale === "fa"
? "جستجوی کشور یا پیش‌شماره..."
: "Search country or dial code..."
}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
aria-label={selectCountryTitle}
className="flex size-7 items-center justify-center rounded-md text-[#667085] hover:bg-white hover:text-[#181818] text-xs font-semibold"
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
@ -700,63 +733,82 @@ export function QuestionPhone({
</div>
</div>
{/* Country Options List */}
<div
ref={listRef}
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-5"
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3"
>
{filteredCountries.length > 0 ? (
filteredCountries.map((c) => {
const cleanCode = c.code.replace(/[^\d]/g, "");
const activeCleanCode = (codeValue || "").replace(
/[^\d]/g,
"",
);
const isSelected = cleanCode === activeCleanCode;
return (
<button
key={`${c.name}-${c.code}`}
type="button"
onClick={() => handleSelectCountryCode(c.code)}
className="flex min-h-[50px] w-full items-center gap-3 border-b border-[#F2F4F7] px-0 text-start cursor-pointer transition-colors hover:bg-[#F9FAFB] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#667085]"
className={[
"flex min-h-12 w-full items-center gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
].join(" ")}
>
{/* Radio Indicator */}
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
{/* Country Flag */}
<span
className="text-[20px] shrink-0"
aria-hidden="true"
>
<span className="text-[20px]" aria-hidden="true">
{c.flag}
</span>
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="truncate text-[15px] font-semibold text-[#181818]">
{/* Country Name & Dial Code */}
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span
className={[
"truncate text-[15px]",
isSelected
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]",
].join(" ")}
>
{c.name}
</span>
<span
dir="ltr"
className="shrink-0 text-[14px] font-medium text-[#667085] tabular-nums"
className={[
"shrink-0 text-[14px] font-medium tabular-nums",
isSelected ? "text-[#F0445B]" : "text-[#667085]",
].join(" ")}
>
{c.code}
</span>
</span>
{c === selectedCountry ? (
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="shrink-0 text-[#F0445B]"
>
<path
d="M4 10.25L8 14L16 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<span aria-hidden="true" className="w-5 shrink-0" />
)}
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
{locale === "fa" ? "موردی یافت نشد" : "No options found"}
</span>
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
</section>

Loading…
Cancel
Save