diff --git a/.zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md b/.zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md new file mode 100644 index 0000000..ed4013d --- /dev/null +++ b/.zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md @@ -0,0 +1,36 @@ +## هدف +اسلاید بین سوالات مثل ریلز اینستاگرام / شورتز یوتیوب: محتوا 1:1 با انگشت حرکت کند، رها کردن بر اساس سرعت (flick) یا مسافت یا commit به بعدی/قبلی یا برگشت، rubber-band در دو انتها، و انیمیشن قابل مقطوع‌سازی (grab mid-animation). + +## تغییرات — فقط `src/components/Componentes/question-snap-list.tsx` + +### 1. لایه‌ی درگ با دنبال کردن انگشت (بدون re-render در هر فریم) +- در `touchstart`: ثبت نقطه/زمان شروع + ارتفاع کانتینر. اگر لمس روی کنترل‌های تعاملی شروع شود (`input, textarea, select, [contenteditable], [data-snap-drag-ignore]`) درگ فعال نمی‌شود تا با تایپ/اسلایدر تداخل نکند. +- در `touchmove` بعد از عبور از آستانه ~۱۰px: اعمال مستقیم `transform` روی سه پنل (فعال، بعدی، قبلی) از طریق ref (بدون state ری‌اکت) → 60fps: + - فعال: `translateY(-offset)` — بالا می‌رود با انگشت + - بعدی: `translateY(height - offset)` — از پایین به‌صورت پنل کامل وارد می‌شود + - قبلی: `translateY(-height - offset)` — از بالا وارد می‌شود +- **Rubber-band**: در سوال اول/آخر، آفست با ضریب مقاومت 0.4 فشرده می‌شود. +- محاسبه‌ی velocity با میانگین متحرک نمایی روی نمونه‌های حرکت. + +### 2. تصمیم رها کردن (touchend) +- commit به بعدی/قبلی اگر: مسافت > 30% ارتفاع **یا** |velocity| > 0.55 px/ms (flick). +- وگرنه spring-back با انیمیشن `cubic-bezier(0.22,1,0.36,1)` ~340ms. +- هنگام commit: همان callback های فعلی (`onQuestionExit`, `onQuestionTransition`) + `setActiveIndex` فراخوانی می‌شود؛ انیمیشن inline ادامه پیدا می‌کند و بعد از پایان، style های inline پاک می‌شوند تا state کلاسی بدون پرش تحویل داده شود. + +### 3. قابل مقطوع‌سازی +- اگر وسط انیمیشن snap دوباره انگشت بگذارد: موقعیت فعلی با `getComputedStyle + DOMMatrix` خوانده و فریز می‌شود و درگ از همان نقطه ادامه می‌یابد (مثل ریلز). + +### 4. حذف پیش‌نمایش‌های ۱۱۰px (peek) +- برای اسلاید پنل کامل ریلز-مانند، پنل‌های قبل/بعد در حالت سکون کاملاً خارج از دید (`translate-y-full` / `-translate-y-full`) قرار می‌گیرند و wrapper های `max-h-[110px] overflow-hidden` حذف می‌شوند. خودِ درگ جای affinity بصری را می‌گیرد. +- wheel دسکتاپ سرجایش می‌ماند و حالا به‌صورت اسلاید کامل انیمیت می‌شود (transition کلاس موجود). + +### 5. حفظ تمام رفتارهای فعلی +حفاظ `dropdown-open` (شیت باز = بدون درگ)، فوکوس خودکار input سوال فعال، init روی اولین سوال بی‌پاسخ، `alignTop`، اسپیسر 20%، `inert`/`aria`، `touchcancel` → برگشت، چند-لمسی → نادیده. + +## خارج از scope +- تغییر رفتار wheel/کیبورد دسکتاپ، مرور‌سازی horizontal، تغییر auto-focus. +- شیت کد کشور phone (همان 68svh) — طبق خواسته فقط QuestionSheet تغییر کرد. + +## راستی‌آزمایی +- `npx vitest run` + `npx tsc --noEmit` (یا biome). +- تست دستی در مرورگر با viewport موبایل: درگ با ماوس (شبیه‌سازی touch)، wheel، اسکرین‌شات قبل/بعد رها کردن، بررسی مرز rubber-band در اولین/آخرین سوال، باز کردن شیت nationality (تغییر 82svh قبلی) و فوکوس input متنی (حرکت به بالا با کیبورد). \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index f7fbd93..cafe0f4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -229,6 +229,48 @@ body[data-page-background="custom"] .app-shell { background-image: var(--page-background-image); } +body.dropdown-open .app-shell { + overflow-y: hidden; +} + +.question-detail-header { + max-height: 120px; + overflow: hidden; + transition: + transform 300ms ease-in-out, + opacity 220ms ease-out, + max-height 300ms ease-in-out, + margin 300ms ease-in-out; +} + +body.question-input-open .app-shell .question-detail-header { + max-height: 0; + margin-top: -80px; + transform: translateY(-150px); + opacity: 0; + pointer-events: none; +} + +body.question-input-open .app-shell .question-snap-list { + padding-top: 0; + padding-bottom: 0; +} + +body.question-input-open + .app-shell + .question-snap-item[aria-current="step"] { + top: 0; + bottom: 0; +} + +body.question-input-open + .app-shell + .question-snap-item[aria-current="step"] + .question-snap-content { + margin-top: 0; + margin-bottom: 0; +} + .page-background-none, .page-background-custom, .page-background-default { diff --git a/src/app/questions-list/[slug]/question-detail-client.test.tsx b/src/app/questions-list/[slug]/question-detail-client.test.tsx index 8ac165d..8259310 100644 --- a/src/app/questions-list/[slug]/question-detail-client.test.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.test.tsx @@ -76,7 +76,7 @@ describe("QuestionDetailClient Validation", () => { }); (useFormOverviewQuery as any).mockReturnValue({ - data: { version: 1 }, + data: {}, isLoading: false, }); (useFormSectionQuery as any).mockReturnValue({ @@ -292,7 +292,6 @@ describe("QuestionDetailClient Validation", () => { ]); (useFormSectionQuery as any).mockReturnValue({ data: { - version: 1, section: { cards: [] }, answers: {}, section_progress: { completion_percent: 0 }, @@ -322,4 +321,93 @@ describe("QuestionDetailClient Validation", () => { // Profile questions render directly, no start button expect(screen.getByText(/Dynamic ID/)).toBeDefined(); }); + + it("renders a stable profile loading shell while section data is cold", () => { + (convertOverviewToFrontendItems as any).mockReturnValue([ + { + slug: "profile_test", + title: "Overview Profile Title", + questions: [], + }, + ]); + (useFormSectionQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useCattellQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + (useGlasserQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + , + ); + + expect(screen.getByText("Overview Profile Title")).toBeDefined(); + expect(screen.getByRole("status")).toBeDefined(); + expect(screen.getByRole("button", { name: "Close" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Info" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + expect(document.querySelector(".shimmer-bg")).toBeNull(); + }); + + it("uses the route title when the overview is also cold", () => { + (convertOverviewToFrontendItems as any).mockReturnValue([]); + (useFormOverviewQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useFormSectionQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useCattellQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + (useGlasserQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + , + ); + + expect(screen.getByText("Route Fallback Title")).toBeDefined(); + expect(screen.getByRole("status")).toBeDefined(); + expect(document.querySelector(".shimmer-bg")).toBeNull(); + }); }); diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index f9c924e..44f5864 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -2,6 +2,8 @@ import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import Button from "@/components/Componentes/button"; +import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import NavigationButton from "@/components/Componentes/navigation-button"; import { PageBackground } from "@/components/Componentes/page-background"; @@ -334,11 +336,56 @@ export default function QuestionDetailClient({ }, [isSchemaLoading, item, questionsListHref, router]); if (isSchemaLoading) { + if (!isAssessment) { + const loadingTitle = overviewItem?.title || title; + + return ( + <> + +
+ +
+ router.push(questionsListHref)} + /> +

+ {loadingTitle} +

+ +
+
+ +
+ +
+ + + + +
+ + ); + } + return ( - + ); } else if (!item) { return null; @@ -480,7 +527,10 @@ export default function QuestionDetailClient({ <>
- +
- +
(null); + const syncPromiseRef = useRef | null>(null); + const syncPendingAnswers = useCallback(async () => { if (!overview) return; - const pendingSections: Array<{ - storageKey: string; - storedValue: { - current_step: number; - fields: MarriageField[]; - pending_keys: string[]; - pending_sync: boolean; - }; - fields: MarriageField[]; - slug: string; - }> = []; - for (const item of questionListItems) { - if ( - item.slug === "personality_test" || - item.slug === "glasser_5_needs_test" - ) { - continue; - } - const storageKey = getQuestionAnswersStorageKey(item.slug); - const rawValue = window.localStorage.getItem(storageKey); - if (!rawValue) continue; - - const storedValue = JSON.parse(rawValue); - if (!storedValue.pending_sync || !Array.isArray(storedValue.fields)) - continue; - const pendingKeys = new Set( - Array.isArray(storedValue.pending_keys) - ? storedValue.pending_keys - : storedValue.fields.map((field: { key: string }) => field.key), - ); - const pendingFields = storedValue.fields.filter( - (field: { key: string }) => pendingKeys.has(field.key), - ); - if (pendingFields.length === 0) continue; - pendingSections.push({ - storageKey, - storedValue, - fields: pendingFields, - slug: item.slug, - }); + if (syncPromiseRef.current) { + return syncPromiseRef.current; } - if (pendingSections.length === 0) return; - const result = await updateMarriageSectionData(pendingSections[0].slug, { - version: overview.version, - current_step: pendingSections[0].storedValue.current_step, - fields: pendingSections.flatMap((section) => section.fields), - }); - applyProfilePatchResultToCache(queryClient, locale, result); - const cleared = new Set(result.cleared_answer_ids ?? []); + const task = (async () => { + const pendingSections: Array<{ + storageKey: string; + storedValue: { + current_step: number; + fields: MarriageField[]; + pending_keys: string[]; + pending_sync: boolean; + }; + fields: MarriageField[]; + slug: string; + }> = []; + for (const item of questionListItems) { + if ( + item.slug === "personality_test" || + item.slug === "glasser_5_needs_test" + ) { + continue; + } + const storageKey = getQuestionAnswersStorageKey(item.slug); + const rawValue = window.localStorage.getItem(storageKey); + if (!rawValue) continue; - for (const { storageKey, storedValue } of pendingSections) { - storedValue.fields = storedValue.fields.filter( - (field: { key: string }) => !cleared.has(field.key), - ); - storedValue.pending_sync = false; - storedValue.pending_keys = []; - if (storedValue.fields.length === 0) { - window.localStorage.removeItem(storageKey); - } else { - window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); + let storedValue: any; + try { + storedValue = JSON.parse(rawValue); + } catch { + continue; + } + + if (!storedValue || !storedValue.pending_sync || !Array.isArray(storedValue.fields)) { + continue; + } + const pendingKeys = new Set( + Array.isArray(storedValue.pending_keys) + ? storedValue.pending_keys + : storedValue.fields.map((field: { key: string }) => field.key), + ); + const pendingFields = storedValue.fields.filter( + (field: { key: string }) => pendingKeys.has(field.key), + ); + if (pendingFields.length === 0) continue; + pendingSections.push({ + storageKey, + storedValue, + fields: pendingFields, + slug: item.slug, + }); } + + if (pendingSections.length === 0) return; + const result = await updateMarriageSectionData(pendingSections[0].slug, { + current_step: pendingSections[0].storedValue.current_step, + fields: pendingSections.flatMap((section) => section.fields), + }); + applyProfilePatchResultToCache(queryClient, locale, result); + const cleared = new Set(result.cleared_answer_ids ?? []); + + for (const { storageKey, storedValue } of pendingSections) { + storedValue.fields = storedValue.fields.filter( + (field: { key: string }) => !cleared.has(field.key), + ); + storedValue.pending_sync = false; + storedValue.pending_keys = []; + if (storedValue.fields.length === 0) { + window.localStorage.removeItem(storageKey); + } else { + window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); + } + } + })(); + + syncPromiseRef.current = task; + try { + await task; + setIsSyncError(false); + } finally { + syncPromiseRef.current = null; } }, [locale, overview, queryClient, questionListItems]); @@ -292,7 +316,7 @@ export default function QuestionsListPage() { ) return; void queryClient.prefetchQuery({ - queryKey: ["marriage", "form-section", "profile", item.slug, locale], + queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }); @@ -316,13 +340,11 @@ export default function QuestionsListPage() { .catch(() => undefined) .then(() => queryClient.fetchQuery({ - queryKey: [ - "marriage", - "form-section", + queryKey: marriageQueryKeys.formSection( "profile", item.slug, locale, - ], + ), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }), @@ -335,66 +357,62 @@ export default function QuestionsListPage() { const prefetchQueueStarted = useRef(false); useEffect(() => { if (prefetchQueueStarted.current || !overview) return; - const likelySections = questionListItems + const profileSections = questionListItems .filter( (item) => - item.required && - (sectionProgressBySlug.get(item.slug) ?? item.progress) < 100, + item.slug !== "personality_test" && + item.slug !== "glasser_5_needs_test", ) - .slice(0, 2); - if (likelySections.length === 0) return; + .sort((first, second) => { + const firstPriority = + first.required && + (sectionProgressBySlug.get(first.slug) ?? first.progress) < 100 + ? 0 + : 1; + const secondPriority = + second.required && + (sectionProgressBySlug.get(second.slug) ?? second.progress) < 100 + ? 0 + : 1; + return firstPriority - secondPriority; + }); + if (profileSections.length === 0) return; prefetchQueueStarted.current = true; let cancelled = false; - const runQueue = async () => { - for (const item of likelySections) { - if (cancelled) return; - try { - await queryClient.fetchQuery({ - queryKey: [ - "marriage", - "form-section", + void prefetchSectionsWithBoundedConcurrency( + profileSections, + (item) => + queryClient.fetchQuery({ + queryKey: marriageQueryKeys.formSection( "profile", item.slug, locale, - ], + ), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, - }); - } catch { - // A later interaction or navigation can retry without blocking the list. - } - } - }; - const schedule = () => void runQueue(); - const idleWindow = window as Window & { - requestIdleCallback?: ( - callback: () => void, - options?: { timeout: number }, - ) => number; - cancelIdleCallback?: (handle: number) => void; - }; - const isIdleScheduled = Boolean(idleWindow.requestIdleCallback); - const handle = idleWindow.requestIdleCallback - ? idleWindow.requestIdleCallback(schedule, { timeout: 750 }) - : globalThis.setTimeout(schedule, 150); + }), + () => cancelled, + ); return () => { cancelled = true; - if (isIdleScheduled) idleWindow.cancelIdleCallback?.(handle as number); - else globalThis.clearTimeout(handle); }; }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); useEffect(() => { - void syncPendingAnswers().catch(() => setIsSyncError(true)); + void syncPendingAnswers().catch((err) => { + console.warn("Background draft sync:", err); + }); const handleOnline = () => { - void syncPendingAnswers().catch(() => setIsSyncError(true)); + void syncPendingAnswers().catch((err) => { + console.warn("Background draft sync on online:", err); + }); }; window.addEventListener("online", handleOnline); return () => window.removeEventListener("online", handleOnline); }, [syncPendingAnswers]); useEffect(() => { - if (startMatchMutation.isError || isSyncError) { + if (startMatchMutation.isError) { setToastMessage( t[ "Sending the match request failed. Please check your connection and try again." @@ -403,10 +421,7 @@ export default function QuestionsListPage() { } }, [ startMatchMutation.isError, - isSyncError, - t[ - "Sending the match request failed. Please check your connection and try again." - ], + t, ]); const handleCloseToast = () => { diff --git a/src/app/questions-list/section-prefetch.test.ts b/src/app/questions-list/section-prefetch.test.ts new file mode 100644 index 0000000..472d150 --- /dev/null +++ b/src/app/questions-list/section-prefetch.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import type { QuestionListItem } from "@/lib/schema-adapter"; +import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; + +function section(slug: string): QuestionListItem { + return { slug } as QuestionListItem; +} + +describe("prefetchSectionsWithBoundedConcurrency", () => { + it("starts promptly and runs at most two section requests at once", async () => { + const sections = [section("one"), section("two"), section("three")]; + const releases: Array<() => void> = []; + let activeRequests = 0; + let maximumActiveRequests = 0; + const prefetch = vi.fn(async () => { + activeRequests += 1; + maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests); + await new Promise((resolve) => releases.push(resolve)); + activeRequests -= 1; + }); + + const queue = prefetchSectionsWithBoundedConcurrency( + sections, + prefetch, + () => false, + ); + + expect(prefetch).toHaveBeenCalledTimes(2); + expect(maximumActiveRequests).toBe(2); + + releases.shift()?.(); + await vi.waitFor(() => expect(prefetch).toHaveBeenCalledTimes(3)); + expect(maximumActiveRequests).toBe(2); + + releases.splice(0).forEach((release) => release()); + await queue; + }); + + it("continues after a background request fails", async () => { + const prefetch = vi + .fn<(item: QuestionListItem) => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(undefined); + + await prefetchSectionsWithBoundedConcurrency( + [section("one"), section("two"), section("three")], + prefetch, + () => false, + ); + + expect(prefetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/app/questions-list/section-prefetch.ts b/src/app/questions-list/section-prefetch.ts new file mode 100644 index 0000000..ac15939 --- /dev/null +++ b/src/app/questions-list/section-prefetch.ts @@ -0,0 +1,24 @@ +import type { QuestionListItem } from "@/lib/schema-adapter"; + +export async function prefetchSectionsWithBoundedConcurrency( + sections: readonly QuestionListItem[], + prefetch: (section: QuestionListItem) => Promise, + isCancelled: () => boolean, +) { + let nextSectionIndex = 0; + + const runWorker = async () => { + while (!isCancelled()) { + const section = sections[nextSectionIndex++]; + if (!section) return; + + try { + await prefetch(section); + } catch { + // Interaction or navigation can retry a failed background prefetch. + } + } + }; + + await Promise.all([runWorker(), runWorker()]); +} diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 3329edb..bcf3019 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -61,7 +61,6 @@ type QuestionAnswersProviderProps = { questions: readonly QuestionField[]; slug: string; locale?: string; - schemaVersion?: number; }; const QuestionAnswersContext = @@ -303,7 +302,6 @@ export function QuestionAnswersProvider({ questions, slug, locale = "en", - schemaVersion = 1, }: QuestionAnswersProviderProps) { const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]); const queryClient = useQueryClient(); @@ -319,7 +317,6 @@ export function QuestionAnswersProvider({ const storageKeyRef = useRef(storageKey); const slugRef = useRef(slug); const backendFieldsRef = useRef([]); - const schemaVersionRef = useRef(schemaVersion); const dirtyKeysRef = useRef(new Set()); const { data: profile } = useMarriageProfileQuery(); @@ -334,16 +331,11 @@ export function QuestionAnswersProvider({ slugRef.current = slug; questionsRef.current = questions; backendFieldsRef.current = serverSectionData?.data || []; - schemaVersionRef.current = Math.max( - schemaVersionRef.current, - schemaVersion, - ); }, [ answers, hasPendingSync, slug, questions, - schemaVersion, serverSectionData?.data, ]); @@ -453,10 +445,16 @@ export function QuestionAnswersProvider({ question.type === "textarea" || question.type === "number"; - const delay = isTextLike ? 1000 : 0; + // The date wheel applies its value on every scroll settle; debounce + // it like text inputs so each spin does not fire a PATCH. + const isDebounced = isTextLike || question.type === "date"; + + const delay = isDebounced ? 1000 : 0; syncTimeoutRef.current = setTimeout(() => { - void flushAnswersRef.current(); + void flushAnswersRef.current().catch(() => { + // The draft stays pending so the next edit or exit can retry. + }); }, delay); return nextAnswers; @@ -491,7 +489,6 @@ export function QuestionAnswersProvider({ const payload = { ...fullPayload, fields: pendingFields, - version: schemaVersionRef.current, }; const revision = answersRevisionRef.current; @@ -500,9 +497,6 @@ export function QuestionAnswersProvider({ } const request = mutateAsync(payload).then((result) => { - if (result?.version) { - schemaVersionRef.current = result.version; - } const cleared = new Set(result?.cleared_answer_ids ?? []); const nextAnswers = { ...answersRef.current }; const questionById = new Map( @@ -621,7 +615,6 @@ export function QuestionAnswersProvider({ fetch(getKeepalivePatchUrl(slugRef.current), { body: JSON.stringify({ - version: schemaVersionRef.current, answers: answersPayload, }), credentials: "include", diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 8c6337c..ddc9caa 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -1,12 +1,16 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { LoadingThreeDot } from "./loading-three-dot"; +import { useSheetScrollLock } from "./use-sheet-scroll-lock"; + +const EXIT_ANIMATION_MS = 220; type QuestionBirthplaceProps = { question: QuestionField; @@ -88,10 +92,12 @@ export function QuestionBirthplace({ const [cityInput, setCityInput] = useState(initial.city); const [isOpen, setIsOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const containerRef = useRef(null); - const listRef = useRef(null); const searchInputRef = useRef(null); + const cityInputRef = useRef(null); + const listRef = useRef(null); + const isMountedRef = useRef(true); const isResidence = question.ui_config?.enable_geoip === true; @@ -99,6 +105,57 @@ export function QuestionBirthplace({ const [isDetecting, setIsDetecting] = useState(false); const [detectedLocation, setDetectedLocation] = useState(""); + useSheetScrollLock(isOpen); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const closeSheet = useCallback(() => { + if (isClosing) return; + setIsClosing(true); + window.setTimeout(() => { + if (isMountedRef.current) { + setIsOpen(false); + setIsClosing(false); + setSearchQuery(""); + } + }, EXIT_ANIMATION_MS); + }, [isClosing]); + + const openSheet = useCallback(() => { + if (disabled) return; + setIsOpen(true); + setIsClosing(false); + }, [disabled]); + + // Handle escape key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeSheet(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, closeSheet]); + + // Auto focus search input when sheet opens + useEffect(() => { + if (isOpen && !isClosing) { + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + } + }, [isOpen, isClosing]); + const updateAnswers = (country: string, city: string) => { const formatted = city && country ? `${city}, ${country}` : city || country || null; @@ -199,9 +256,12 @@ export function QuestionBirthplace({ localStorage.setItem("hasCheckedGeoIPResidence", "true"); } setMode("manual"); - setSelectedCountry(""); - setCityInput(""); - updateAnswers("", ""); + const parsed = parseValue(rawValue); + const country = selectedCountry || parsed.country; + const city = cityInput || parsed.city; + setSelectedCountry(country); + setCityInput(city); + updateAnswers(country, city); }; // Synchronize state if rawValue changes externally @@ -218,62 +278,6 @@ export function QuestionBirthplace({ } }, [rawValue]); - // Lock page scroll when dropdown is open - useEffect(() => { - if (isOpen) { - document.body.classList.add("dropdown-open"); - document.body.style.overflow = "hidden"; - document.documentElement.style.overflow = "hidden"; - } else { - document.body.classList.remove("dropdown-open"); - document.body.style.overflow = ""; - document.documentElement.style.overflow = ""; - } - return () => { - document.body.classList.remove("dropdown-open"); - document.body.style.overflow = ""; - document.documentElement.style.overflow = ""; - }; - }, [isOpen]); - - useEffect(() => { - if (isOpen) { - const timer = setTimeout(() => { - searchInputRef.current?.focus(); - }, 50); - return () => clearTimeout(timer); - } - }, [isOpen]); - - const handleListWheel = useCallback((e: React.WheelEvent) => { - e.stopPropagation(); - const el = listRef.current; - if (!el) return; - - const atTop = el.scrollTop <= 0 && e.deltaY < 0; - const atBottom = - el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0; - - if (atTop || atBottom) { - e.preventDefault(); - } - }, []); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); - const options = getCountryList(locale); const filteredOptions = options.filter((option) => option.toLowerCase().includes(searchQuery.toLowerCase()), @@ -284,9 +288,11 @@ export function QuestionBirthplace({ localStorage.setItem("hasCheckedGeoIPResidence", "true"); } setSelectedCountry(country); - setCityInput(""); - setIsOpen(false); - updateAnswers(country, ""); + closeSheet(); + updateAnswers(country, cityInput); + window.setTimeout(() => { + cityInputRef.current?.focus({ preventScroll: true }); + }, EXIT_ANIMATION_MS); }; const handleCityChange = (e: React.ChangeEvent) => { @@ -304,9 +310,24 @@ export function QuestionBirthplace({ question.extras?.placeHolder || (isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood"); + const searchPlaceholder = + locale === "fa" + ? "جستجو..." + : locale === "ar" + ? "بحث..." + : locale === "tr" + ? "Ara..." + : "Search..."; + + const noResultsText = + locale === "fa" + ? "موردی یافت نشد" + : locale === "ar" + ? "لم يتم العثور على نتائج" + : "No options found"; + return (
- {/* Country Selection Dropdown */} + {/* Country Selection Trigger */}
- - {isOpen && ( -
- {options.length > 3 || searchQuery ? ( -
- - - - - setSearchQuery(e.target.value)} - placeholder="Search..." - className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" - /> - {searchQuery ? ( - - ) : null} -
- ) : null} - -
e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} - onTouchEnd={(e) => e.stopPropagation()} - className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1" - > - {filteredOptions.length > 0 ? ( - filteredOptions.map((option) => { - const isSelected = selectedCountry === option; - - return ( - - ); - }) - ) : ( - - No options found - - )} -
-
- )}
{/* City Text Input */}
) : ( <> - {/* 1. Country Selection Dropdown */} + {/* 1. Country Selection Trigger */}
- - {isOpen && ( -
- {options.length > 3 || searchQuery ? ( -
- - - - - setSearchQuery(e.target.value)} - placeholder="Search..." - className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" - /> - {searchQuery ? ( - - ) : null} -
- ) : null} - -
e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} - onTouchEnd={(e) => e.stopPropagation()} - className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1" - > - {filteredOptions.length > 0 ? ( - filteredOptions.map((option) => { - const isSelected = selectedCountry === option; - - return ( - - ); - }) - ) : ( - - No options found - - )} -
-
- )}
{/* 2. City Text Input */}
)} + + {/* Country Selection Bottom Sheet Modal */} + {isOpen && + createPortal( +
event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + onClick={(e) => { + if (e.target === e.currentTarget) { + closeSheet(); + } + }} + > +
e.stopPropagation()} + > + {/* Drag Handle Notch */} +
+
+
+ + {/* Header with Title and Close Button */} +
+

+ {selectCountryPlaceholder} +

+ +
+ + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={searchPlaceholder} + className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" + /> + {searchQuery ? ( + + ) : null} +
+
+ + {/* Country Options List */} +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => { + const isSelected = selectedCountry === option; + + return ( + + ); + }) + ) : ( +
+ {noResultsText} +
+ )} +
+
+
, + document.body, + )}
); } diff --git a/src/components/Componentes/question-date-sheet.tsx b/src/components/Componentes/question-date-sheet.tsx new file mode 100644 index 0000000..6b9514e --- /dev/null +++ b/src/components/Componentes/question-date-sheet.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import type { QuestionField } from "@/lib/schema-adapter"; +import { useI18n } from "@/translations/provider"; +import { useSheetScrollLock } from "./use-sheet-scroll-lock"; + +const EXIT_ANIMATION_MS = 300; +const WHEEL_ITEM_HEIGHT = 48; +const WHEEL_EDGE_PADDING = 108; +const SCROLL_SETTLE_MS = 90; + +const MONTH_VALUES = [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "10", + "11", + "12", +]; + +const MIN_AGE = 18; +const currentYear = new Date().getFullYear(); +const maxBirthYear = currentYear - MIN_AGE; +const YEARS = Array.from({ length: 80 }, (_, i) => + (maxBirthYear - i).toString(), +); + +function daysInMonth(year: string, month: string): number { + const y = Number.parseInt(year, 10); + const m = Number.parseInt(month, 10); + if (!y || !m) return 31; + return new Date(y, m, 0).getDate(); +} + +type DatePart = "day" | "month" | "year"; + +type QuestionDateSheetProps = { + question: QuestionField; + value: string; + onApply: (formattedDate: string) => void; + onClose: () => void; +}; + +function WheelColumn({ + ariaLabel, + items, + selectedIndex, + onSelect, +}: { + ariaLabel: string; + items: Array<{ value: string; label: ReactNode }>; + selectedIndex: number; + onSelect: (index: number) => void; +}) { + const listRef = useRef(null); + const settleTimeoutRef = useRef(null); + const initialScrollDoneRef = useRef(false); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + const list = listRef.current; + if (!list || initialScrollDoneRef.current) return; + initialScrollDoneRef.current = true; + list.scrollTop = selectedIndex * WHEEL_ITEM_HEIGHT; + }); + return () => window.cancelAnimationFrame(frame); + }, []); + + useEffect(() => { + return () => { + if (settleTimeoutRef.current !== null) { + window.clearTimeout(settleTimeoutRef.current); + } + }; + }, []); + + const handleScroll = useCallback(() => { + const list = listRef.current; + if (!list) return; + if (settleTimeoutRef.current !== null) { + window.clearTimeout(settleTimeoutRef.current); + } + settleTimeoutRef.current = window.setTimeout(() => { + settleTimeoutRef.current = null; + const index = Math.max( + 0, + Math.min( + items.length - 1, + Math.round(list.scrollTop / WHEEL_ITEM_HEIGHT), + ), + ); + if (index !== selectedIndex) { + onSelect(index); + } + }, SCROLL_SETTLE_MS); + }, [items.length, onSelect, selectedIndex]); + + return ( +
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + className="h-[264px] flex-1 snap-y snap-mandatory overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + > + {showInvalidState ? ( @@ -655,90 +623,146 @@ export function QuestionPhone({ ) : null} - {/* Dropdown Options Panel */} - {isOpen && ( -
- {/* Search Input Bar */} -
- - - - - setSearchQuery(e.target.value)} - placeholder={ - locale === "fa" - ? "جستجوی کشور یا پیش‌شماره..." - : "Search country or dial code..." - } - className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" - /> - {searchQuery ? ( - - ) : null} -
- - {/* Country List */} + {/* The phone field moves up while the country sheet enters from below. */} + {isOpen && + createPortal(
{ + if (event.key === "Escape") closeSheet(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) closeSheet(); + }} > - {filteredCountries.length > 0 ? ( - filteredCountries.map((c) => { - return ( - - ); - }) - ) : ( - - {locale === "fa" ? "موردی یافت نشد" : "No options found"} - - )} -
-
- )} + + + + setSearchQuery(e.target.value)} + aria-label={selectCountryTitle} + placeholder={ + locale === "fa" + ? "جستجوی کشور یا پیش‌شماره..." + : "Search country or dial code..." + } + className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" + /> + {searchQuery ? ( + + ) : null} +
+
+ +
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" + > + {filteredCountries.length > 0 ? ( + filteredCountries.map((c) => { + return ( + + ); + }) + ) : ( + + {locale === "fa" ? "موردی یافت نشد" : "No options found"} + + )} +
+ +
, + document.body, + )}
); } diff --git a/src/components/Componentes/question-renderer.tsx b/src/components/Componentes/question-renderer.tsx index f9843b1..dcf20c7 100644 --- a/src/components/Componentes/question-renderer.tsx +++ b/src/components/Componentes/question-renderer.tsx @@ -5,7 +5,7 @@ import QuestionBirthplace from "./question-birthplace"; import QuestionButton from "./question-button"; import QuestionCheckbox from "./question-checkbox"; import QuestionDate from "./question-date"; -import QuestionDropdown from "./question-dropdown"; +import QuestionSheet from "./question-sheet"; import QuestionFile from "./question-file"; import QuestionNumber from "./question-number"; import QuestionPhone from "./question-phone"; @@ -62,7 +62,7 @@ export function QuestionRenderer({ ); case "dropdown": return ( - diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx new file mode 100644 index 0000000..6f7ae67 --- /dev/null +++ b/src/components/Componentes/question-sheet.test.tsx @@ -0,0 +1,182 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +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 { QuestionAnswersProvider } from "./question-answer-storage"; +import { QuestionSheet } from "./question-sheet"; + +vi.mock("@/translations/provider", () => ({ + useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })), +})); + +vi.mock("@/hooks/marriage/use-section-data", () => ({ + applyProfilePatchResultToCache: vi.fn(), + useMarriageSectionDataQuery: vi.fn(() => ({ + data: undefined, + isLoading: false, + })), + useUpdateMarriageSectionDataMutation: vi.fn(() => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + })), +})); + +describe("QuestionSheet component", () => { + afterEach(() => { + cleanup(); + document.body.classList.remove("dropdown-open"); + document.body.classList.remove("question-input-open"); + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + it("renders trigger and opens bottom sheet on click", async () => { + const qDropdown = { + id: "q_nat", + title: "تابعیت و شهروندی", + type: "dropdown", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "انتخاب کشور" }, + options: [ + { id: "iran", value: "Iran", label: "ایران", order: 1 }, + { id: "germany", value: "Germany", label: "آلمان", order: 2 }, + { id: "canada", value: "Canada", label: "کانادا", order: 3 }, + ], + ui_config: {}, + } as QuestionField; + + render( +
+ + + + + +
, + ); + + // Initial placeholder displayed in trigger + expect(screen.getByText("انتخاب کشور")).toBeDefined(); + + // Click trigger to open bottom sheet + const trigger = screen.getByRole("button", { name: /انتخاب کشور/i }); + fireEvent.click(trigger); + + // Bottom sheet dialog should be visible + expect(screen.getByRole("dialog")).toBeDefined(); + expect(document.body.classList.contains("dropdown-open")).toBe(true); + expect(document.body.classList.contains("question-input-open")).toBe(true); + expect( + (document.querySelector(".app-shell") as HTMLElement).style.overflowY, + ).toBe("hidden"); + expect(screen.getByRole("button", { name: "ایران" })).toBeDefined(); + expect(screen.getByRole("button", { name: "آلمان" })).toBeDefined(); + + // Select Iran + const iranBtn = screen.getByRole("button", { name: "ایران" }); + fireEvent.click(iranBtn); + + // Trigger should now show Iran + await waitFor(() => { + expect(screen.getByText("ایران")).toBeDefined(); + expect(document.body.classList.contains("dropdown-open")).toBe(false); + expect(document.body.classList.contains("question-input-open")).toBe( + false, + ); + expect( + (document.querySelector(".app-shell") as HTMLElement).style.overflowY, + ).toBe(""); + }); + }); + + it("supports multi-selection with confirm button", async () => { + const qMulti = { + id: "q_lang", + title: "سایر زبان‌ها", + type: "dropdown", + order: 2, + required: false, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "انتخاب زبان‌ها", range: [0, 5] }, + options: [ + { id: "en", value: "English", label: "انگلیسی", order: 1 }, + { id: "ar", value: "Arabic", label: "عربی", order: 2 }, + { id: "fr", value: "French", label: "فرانسوی", order: 3 }, + ], + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + // Click trigger + const trigger = screen.getByRole("button", { name: /انتخاب زبان‌ها/i }); + fireEvent.click(trigger); + + // Select English and French + const enBtn = screen.getByRole("button", { name: "انگلیسی" }); + const frBtn = screen.getByRole("button", { name: "فرانسوی" }); + fireEvent.click(enBtn); + fireEvent.click(frBtn); + + // Confirm button should show count + const confirmBtn = screen.getByRole("button", { name: /تایید \(2\)/i }); + expect(confirmBtn).toBeDefined(); + fireEvent.click(confirmBtn); + + // Trigger displays selected items + await waitFor(() => { + expect(screen.getByText("انگلیسی, فرانسوی")).toBeDefined(); + }); + }); + + it("closes the inline selector with Escape", async () => { + const question = { + id: "q_drag", + title: "کشور محل اقامت", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب کشور" }, + options: [{ id: "iran", value: "Iran", label: "ایران", order: 1 }], + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.body.classList.contains("dropdown-open")).toBe(false); + expect(document.body.classList.contains("question-input-open")).toBe( + false, + ); + }); + }); +}); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx new file mode 100644 index 0000000..bf8fc8d --- /dev/null +++ b/src/components/Componentes/question-sheet.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { QuestionField } from "@/lib/schema-adapter"; +import { useI18n } from "@/translations/provider"; +import { Button } from "./button"; +import { ExplanationUiFont } from "./explanation-ui-font"; +import { useQuestionAnswers } from "./question-answer-storage"; +import QuestionTitle from "./question-title"; +import { useSheetScrollLock } from "./use-sheet-scroll-lock"; + +const EXIT_ANIMATION_MS = 300; + +export type QuestionSheetProps = { + question: QuestionField; + disabled?: boolean; +}; + +export function QuestionSheet({ question, disabled }: QuestionSheetProps) { + const { dictionary: t, locale } = useI18n(); + const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); + const rawValue = getAnswerValue(question); + + const isMulti = + Array.isArray(rawValue) || + question.type === "checkbox" || + (question.extras?.range && question.extras.range[1] > 1); + + const selectedList = Array.isArray(rawValue) + ? rawValue + : typeof rawValue === "string" && rawValue + ? [rawValue] + : []; + const singleValue = typeof rawValue === "string" ? rawValue : ""; + + const [isOpen, setIsOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const listRef = useRef(null); + const closeSheet = useCallback(() => { + setIsClosing(true); + window.setTimeout(() => { + setIsOpen(false); + setIsClosing(false); + setSearchQuery(""); + }, EXIT_ANIMATION_MS); + }, []); + + const openSheet = useCallback(() => { + if (disabled) return; + setIsOpen(true); + setIsClosing(false); + }, [disabled]); + + useSheetScrollLock(isOpen); + + // Handle escape key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeSheet(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, closeSheet]); + + const options = question.options || []; + + const showSearch = (() => { + if (question.extras?.noSearch) return false; + return options.length > 5; + })(); + + const filteredOptions = options.filter((option) => + option.label.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + const getCleanLabel = (optId: string) => { + const opt = options.find((o) => o.id === optId); + if (!opt) return optId; + return opt.label.split(" - ")[0]; + }; + + const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; + const defaultPlaceholder = isRtl ? "انتخاب کنید" : "Select"; + + const displayLabel = isMulti + ? selectedList.length > 0 + ? selectedList.map(getCleanLabel).join(", ") + : question.extras?.placeHolder || defaultPlaceholder + : singleValue + ? getCleanLabel(singleValue) + : question.extras?.placeHolder || defaultPlaceholder; + + const hasSelectedValue = isMulti + ? selectedList.length > 0 + : Boolean(singleValue); + + const toggleMultiOption = (optionId: string) => { + let nextValue: string[]; + if (selectedList.includes(optionId)) { + nextValue = selectedList.filter((v) => v !== optionId); + } else { + nextValue = [...selectedList, optionId]; + } + setAnswerValue(question, nextValue.length > 0 ? nextValue : null); + }; + + const handleSelectSingle = (optionId: string) => { + setAnswerValue(question, optionId); + closeSheet(); + }; + + const searchPlaceholder = + locale === "fa" + ? "جستجو..." + : locale === "ar" + ? "بحث..." + : locale === "tr" + ? "Ara..." + : "Search..."; + + const noResultsText = + locale === "fa" + ? "موردی یافت نشد" + : locale === "ar" + ? "لم يتم العثور على نتائج" + : "No options found"; + + const confirmText = t.Confirm || (isRtl ? "تایید" : "Confirm"); + + return ( +
+ + + {/* Select Trigger Button (Input style) */} + + + {/* The active field moves up while the options sheet enters from below. */} + {isOpen && + createPortal( +
{ + if (e.key === "Escape") closeSheet(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) closeSheet(); + }} + > +
+
+

+ {question.title} +

+ +
+ + {/* Search Bar */} + {showSearch && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder={searchPlaceholder} + className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" + /> + {searchQuery ? ( + + ) : null} +
+
+ )} + + {/* Options List */} +
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3" + > + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => { + const isSelected = isMulti + ? selectedList.includes(option.id) + : singleValue === option.id; + + return ( + + ); + }) + ) : ( +
+ {noResultsText} +
+ )} +
+ + {/* Bottom Actions for Multi-Select */} + {isMulti && ( +
+ +
+ )} +
+
, + document.body, + )} +
+ ); +} + +export default QuestionSheet; diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index eed789e..6728f90 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -11,14 +11,44 @@ import { } from "react"; import { useI18n } from "@/translations/provider"; import { useQuestionProgress } from "./question-progress-tracker"; +import { useQuestionInputFocusSync } from "./use-sheet-scroll-lock"; const WHEEL_GESTURE_IDLE_MS = 320; const TOUCH_MIN_DISTANCE = 8; +const DRAG_ENGAGE_DISTANCE = 10; +const DRAG_COMMIT_RATIO = 0.3; +const DRAG_FLICK_VELOCITY = 0.55; +const SNAP_ANIMATION_MS = 340; +const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; +const RUBBER_BAND_RESISTANCE = 0.4; const AUTO_FOCUS_SELECTOR = [ - "textarea:not([disabled])", - 'input[type="text"]:not([disabled])', - 'input[type="number"]:not([disabled])', + "textarea:not([disabled]):not([data-no-auto-focus])", + 'input[type="text"]:not([disabled]):not([data-no-auto-focus])', + 'input[type="number"]:not([disabled]):not([data-no-auto-focus])', ].join(", "); +const DRAG_IGNORE_SELECTOR = [ + "input", + "textarea", + "select", + '[contenteditable="true"]', + "[data-snap-drag-ignore]", +].join(", "); + +type SnapDragState = { + pointerDown: boolean; + ignored: boolean; + engaged: boolean; + animating: boolean; + baseOffset: number; + startY: number; + lastY: number; + lastMoveTime: number; + velocity: number; + offset: number; + height: number; + trioIndices: number[]; + cleanupTimer: number | null; +}; type QuestionSnapListProps = { children: ReactNode; @@ -41,15 +71,36 @@ export function QuestionSnapList({ }: QuestionSnapListProps) { const { dictionary: t } = useI18n(); const { isCompleted } = useQuestionProgress(); + useQuestionInputFocusSync(); const questions = Children.toArray(children); const wheelLockedRef = useRef(false); const wheelUnlockTimeoutRef = useRef(null); const touchStartYRef = useRef(null); const questionRefs = useRef>([]); + const containerRef = useRef(null); const previousActiveIndexRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); - + const activeIndexRef = useRef(activeIndex); + activeIndexRef.current = activeIndex; + const questionsCountRef = useRef(questions.length); + questionsCountRef.current = questions.length; + + const dragRef = useRef({ + pointerDown: false, + ignored: false, + engaged: false, + animating: false, + baseOffset: 0, + startY: 0, + lastY: 0, + lastMoveTime: 0, + velocity: 0, + offset: 0, + height: 0, + trioIndices: [], + cleanupTimer: null, + }); const stepQuestion = useCallback( (direction: 1 | -1) => { @@ -223,9 +274,97 @@ export function QuestionSnapList({ if (wheelUnlockTimeoutRef.current !== null) { window.clearTimeout(wheelUnlockTimeoutRef.current); } + if (dragRef.current.cleanupTimer !== null) { + window.clearTimeout(dragRef.current.cleanupTimer); + } }; }, []); + const readTranslateY = useCallback((element: HTMLElement): number => { + const transform = window.getComputedStyle(element).transform; + if (!transform || transform === "none") { + return 0; + } + + try { + return new DOMMatrix(transform).m42; + } catch { + return 0; + } + }, []); + + const styleDragPanel = useCallback( + (element: HTMLDivElement, transform: string, animate: boolean) => { + element.style.transition = animate + ? `transform ${SNAP_ANIMATION_MS}ms ${SNAP_EASE}` + : "none"; + element.style.transform = transform; + element.style.opacity = "1"; + element.style.zIndex = "10"; + }, + [], + ); + + // Positive offset slides the active panel up (towards the next question), + // exactly like following a finger in a reels-style pager. + const applyDragOffset = useCallback( + (offset: number, animate: boolean) => { + const index = activeIndexRef.current; + const height = dragRef.current.height; + + const panels: Array<[HTMLDivElement | null, number]> = [ + [questionRefs.current[index], -offset], + [questionRefs.current[index + 1], height - offset], + [questionRefs.current[index - 1], -height - offset], + ]; + + for (const [element, translateY] of panels) { + if (!element) continue; + styleDragPanel(element, `translateY(${translateY}px)`, animate); + } + }, + [styleDragPanel], + ); + + const clearDragStyles = useCallback(() => { + const drag = dragRef.current; + const index = activeIndexRef.current; + const targets = new Set([ + ...drag.trioIndices, + index - 1, + index, + index + 1, + ]); + for (const position of targets) { + const element = questionRefs.current[position]; + if (!element) continue; + element.style.transition = ""; + element.style.transform = ""; + element.style.opacity = ""; + element.style.zIndex = ""; + } + drag.trioIndices = []; + }, []); + + const snapPanelsTo = useCallback( + (offset: number) => { + const drag = dragRef.current; + const index = activeIndexRef.current; + drag.animating = true; + drag.trioIndices = [index - 1, index, index + 1]; + applyDragOffset(offset, true); + if (drag.cleanupTimer !== null) { + window.clearTimeout(drag.cleanupTimer); + } + drag.cleanupTimer = window.setTimeout(() => { + drag.cleanupTimer = null; + drag.animating = false; + clearDragStyles(); + }, SNAP_ANIMATION_MS + 40); + }, + [applyDragOffset, clearDragStyles], + ); + const handleWheel = useCallback( (event: React.WheelEvent) => { if (document.body.classList.contains("dropdown-open")) { @@ -255,9 +394,152 @@ export function QuestionSnapList({ if (document.body.classList.contains("dropdown-open")) { return; } - touchStartYRef.current = event.touches[0]?.clientY ?? null; + + const drag = dragRef.current; + if (drag.cleanupTimer !== null) { + window.clearTimeout(drag.cleanupTimer); + drag.cleanupTimer = null; + } + + const target = event.target as HTMLElement | null; + drag.ignored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR)); + + drag.height = + containerRef.current?.getBoundingClientRect().height ?? + window.innerHeight; + + if (drag.animating) { + // Grabbed mid-snap (or mid wheel transition): freeze the panels + // exactly where they currently are and continue the drag from there. + const activeElement = questionRefs.current[activeIndexRef.current]; + if (activeElement) { + drag.baseOffset = -readTranslateY(activeElement); + applyDragOffset(drag.baseOffset, false); + } else { + drag.baseOffset = 0; + } + drag.animating = false; + drag.engaged = true; + } else { + drag.baseOffset = 0; + drag.engaged = false; + } + + drag.pointerDown = true; + drag.velocity = 0; + drag.offset = drag.baseOffset; + drag.startY = event.touches[0]?.clientY ?? 0; + drag.lastY = drag.startY; + drag.lastMoveTime = event.timeStamp || performance.now(); + touchStartYRef.current = drag.startY; }, - [], + [applyDragOffset, readTranslateY], + ); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (document.body.classList.contains("dropdown-open")) { + return; + } + + const drag = dragRef.current; + + if (!drag.pointerDown) { + event.preventDefault(); + return; + } + + if (drag.ignored) { + return; + } + + const y = event.touches[0]?.clientY ?? drag.lastY; + + if (!drag.engaged) { + if (Math.abs(drag.startY - y) < DRAG_ENGAGE_DISTANCE) { + event.preventDefault(); + return; + } + drag.engaged = true; + } + + event.preventDefault(); + + if (event.touches.length > 1) { + drag.engaged = false; + drag.pointerDown = false; + snapPanelsTo(0); + return; + } + + const now = event.timeStamp || performance.now(); + const deltaTime = now - drag.lastMoveTime; + if (deltaTime > 0) { + const instantVelocity = (drag.lastY - y) / deltaTime; + drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28; + } + drag.lastY = y; + drag.lastMoveTime = now; + + const index = activeIndexRef.current; + const canNext = index < questionsCountRef.current - 1; + const canPrev = index > 0; + let offset = drag.baseOffset + (drag.startY - y); + if (offset > 0 && !canNext) { + offset *= RUBBER_BAND_RESISTANCE; + } + if (offset < 0 && !canPrev) { + offset *= RUBBER_BAND_RESISTANCE; + } + drag.offset = offset; + + applyDragOffset(offset, false); + }, + [applyDragOffset, snapPanelsTo], + ); + + const finishDrag = useCallback( + (cancelled: boolean) => { + const drag = dragRef.current; + if (!drag.pointerDown) { + return; + } + drag.pointerDown = false; + touchStartYRef.current = null; + + if (!drag.engaged) { + return; + } + drag.engaged = false; + + const index = activeIndexRef.current; + const canNext = index < questionsCountRef.current - 1; + const canPrev = index > 0; + const flicked = Math.abs(drag.velocity) > DRAG_FLICK_VELOCITY; + const draggedFar = + Math.abs(drag.offset) > drag.height * DRAG_COMMIT_RATIO; + + let direction: 0 | 1 | -1 = 0; + if (!cancelled && (draggedFar || flicked)) { + if ((drag.offset > 0 || drag.velocity > 0) && canNext) { + direction = 1; + } else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) { + direction = -1; + } + } + + if (direction === 0) { + snapPanelsTo(0); + return; + } + + const nextIndex = index + direction; + snapPanelsTo(direction * drag.height); + onQuestionExit?.(index, nextIndex); + onQuestionTransition?.(index, nextIndex); + setActiveIndex(nextIndex); + }, + [onQuestionExit, onQuestionTransition, snapPanelsTo], ); const handleTouchEnd = useCallback( @@ -266,9 +548,22 @@ export function QuestionSnapList({ return; } + const drag = dragRef.current; + + if (drag.engaged) { + finishDrag(false); + return; + } + + if (!drag.pointerDown) { + return; + } + drag.pointerDown = false; + + // Fallback for very fast flicks whose touchmove never engaged the + // drag layer: fall back to the distance-based step. const startY = touchStartYRef.current; const endY = event.changedTouches[0]?.clientY; - touchStartYRef.current = null; if (startY === null || endY === undefined || questions.length < 2) { @@ -283,18 +578,12 @@ export function QuestionSnapList({ stepQuestion(distance > 0 ? 1 : -1); }, - [questions.length, stepQuestion], + [finishDrag, questions.length, stepQuestion], ); - const handleTouchMove = useCallback( - (event: React.TouchEvent) => { - if (document.body.classList.contains("dropdown-open")) { - return; - } - event.preventDefault(); - }, - [], - ); + const handleTouchCancel = useCallback(() => { + finishDrag(true); + }, [finishDrag]); if (questions.length === 0) { return null; @@ -302,14 +591,16 @@ export function QuestionSnapList({ return (
-
{question}
+
+ {question} +
{isActive && (