diff --git a/src/app/globals.css b/src/app/globals.css index cafe0f4..0a20475 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -243,7 +243,16 @@ body.dropdown-open .app-shell { margin 300ms ease-in-out; } -body.question-input-open .app-shell .question-detail-header { +.question-progress, +.question-snap-content { + transition: + transform 300ms ease-in-out, + opacity 220ms ease-out, + max-height 300ms ease-in-out, + padding 300ms ease-in-out; +} + +body.question-sheet-open .app-shell .question-detail-header { max-height: 0; margin-top: -80px; transform: translateY(-150px); @@ -251,19 +260,25 @@ body.question-input-open .app-shell .question-detail-header { pointer-events: none; } -body.question-input-open .app-shell .question-snap-list { +body.question-sheet-open .app-shell .question-progress { + max-height: 0; padding-top: 0; padding-bottom: 0; + opacity: 0; + overflow: hidden; } -body.question-input-open - .app-shell - .question-snap-item[aria-current="step"] { +body.question-sheet-open .app-shell .question-snap-list { + padding-top: 0; + padding-bottom: 0; +} + +body.question-sheet-open .app-shell .question-snap-item[aria-current="step"] { top: 0; bottom: 0; } -body.question-input-open +body.question-sheet-open .app-shell .question-snap-item[aria-current="step"] .question-snap-content { @@ -271,6 +286,13 @@ body.question-input-open margin-bottom: 0; } +body.question-keyboard-open + .app-shell + .question-snap-item[aria-current="step"] + .question-snap-content { + transform: translateY(-48px); +} + .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 8259310..49713c1 100644 --- a/src/app/questions-list/[slug]/question-detail-client.test.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.test.tsx @@ -369,6 +369,62 @@ describe("QuestionDetailClient Validation", () => { expect(document.querySelector(".shimmer-bg")).toBeNull(); }); + it("renders cached section questions while the overview refreshes", () => { + const cachedItem = { + slug: "profile_test", + title: "Cached Profile Form", + questions: [ + { + id: "cached_question", + title: "Cached question", + type: "text", + order: 1, + required: true, + isVisible: true, + private: false, + description: "", + tooltip: "", + extras: { placeHolder: "", range: [0, 0], options: [] }, + options: [], + }, + ], + }; + (convertOverviewToFrontendItems as any).mockReturnValue([]); + (useFormOverviewQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useFormSectionQuery as any).mockReturnValue({ + data: { + section: { cards: [] }, + answers: {}, + section_progress: { completion_percent: 0 }, + }, + isLoading: false, + }); + (mapBackendSectionToFrontend as any).mockReturnValue(cachedItem); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + + expect(screen.getByRole("textbox")).toBeDefined(); + expect(screen.queryByRole("status")).toBeNull(); + }); + it("uses the route title when the overview is also cold", () => { (convertOverviewToFrontendItems as any).mockReturnValue([]); (useFormOverviewQuery as any).mockReturnValue({ diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 44f5864..8031612 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -207,14 +207,17 @@ export default function QuestionDetailClient({ ); const overviewItem = items.find((candidate) => candidate.slug === itemSlug); const item = useMemo(() => { - if (!overviewItem || isAssessment || !sectionResponse) return overviewItem; - return mapBackendSectionToFrontend( - sectionResponse.section, - sectionResponse.section_progress.completion_percent, - ); + if (!isAssessment && sectionResponse) { + return mapBackendSectionToFrontend( + sectionResponse.section, + sectionResponse.section_progress.completion_percent, + ); + } + return overviewItem; }, [isAssessment, overviewItem, sectionResponse]); - const isSchemaLoading = - isOverviewLoading || (!isAssessment && isSectionLoading); + const isSchemaLoading = isAssessment + ? isOverviewLoading + : !sectionResponse && (isOverviewLoading || isSectionLoading); const cattellQuery = useCattellQuestionsQuery(locale, { enabled: isCattellSlug && isTestStarted, @@ -384,9 +387,7 @@ export default function QuestionDetailClient({ ); } - return ( - - ); + return ; } else if (!item) { return null; } diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index dc17c0f..c780379 100644 --- a/src/app/questions-list/page.tsx +++ b/src/app/questions-list/page.tsx @@ -255,7 +255,11 @@ export default function QuestionsListPage() { continue; } - if (!storedValue || !storedValue.pending_sync || !Array.isArray(storedValue.fields)) { + if ( + !storedValue || + !storedValue.pending_sync || + !Array.isArray(storedValue.fields) + ) { continue; } const pendingKeys = new Set( @@ -383,14 +387,10 @@ export default function QuestionsListPage() { profileSections, (item) => queryClient.fetchQuery({ - queryKey: marriageQueryKeys.formSection( - "profile", - item.slug, - locale, - ), - queryFn: () => getFormSection("profile", item.slug, locale), - staleTime: 30 * 1000, - }), + queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), + queryFn: () => getFormSection("profile", item.slug, locale), + staleTime: 30 * 1000, + }), () => cancelled, ); return () => { @@ -419,10 +419,7 @@ export default function QuestionsListPage() { ], ); } - }, [ - startMatchMutation.isError, - t, - ]); + }, [startMatchMutation.isError, t]); const handleCloseToast = () => { setToastMessage(null); diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index ddc9caa..706385b 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -94,7 +94,6 @@ export function QuestionBirthplace({ const [isOpen, setIsOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); const cityInputRef = useRef(null); const listRef = useRef(null); const isMountedRef = useRef(true); @@ -146,16 +145,6 @@ export function QuestionBirthplace({ 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; @@ -651,7 +640,6 @@ export function QuestionBirthplace({ /> setSearchQuery(e.target.value)} diff --git a/src/components/Componentes/question-progress-tracker.tsx b/src/components/Componentes/question-progress-tracker.tsx index 6f324e2..e13bbff 100644 --- a/src/components/Componentes/question-progress-tracker.tsx +++ b/src/components/Componentes/question-progress-tracker.tsx @@ -171,7 +171,7 @@ export function QuestionProgressTracker({ onChange={updateProgress} onInput={updateProgress} > -
+
{ afterEach(() => { cleanup(); document.body.classList.remove("dropdown-open"); - document.body.classList.remove("question-input-open"); + document.body.classList.remove("question-sheet-open"); + document.body.classList.remove("question-keyboard-open"); }); const queryClient = new QueryClient({ @@ -77,7 +78,7 @@ describe("QuestionSheet component", () => { // 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.body.classList.contains("question-sheet-open")).toBe(true); expect( (document.querySelector(".app-shell") as HTMLElement).style.overflowY, ).toBe("hidden"); @@ -92,7 +93,7 @@ describe("QuestionSheet component", () => { await waitFor(() => { expect(screen.getByText("ایران")).toBeDefined(); expect(document.body.classList.contains("dropdown-open")).toBe(false); - expect(document.body.classList.contains("question-input-open")).toBe( + expect(document.body.classList.contains("question-sheet-open")).toBe( false, ); expect( @@ -174,9 +175,73 @@ describe("QuestionSheet component", () => { 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( + expect(document.body.classList.contains("question-sheet-open")).toBe( false, ); }); }); + + it("opens a searchable sheet without focusing search", () => { + const question = { + id: "q_country", + title: "کشور", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب کشور" }, + options: Array.from({ length: 6 }, (_, index) => ({ + id: `country-${index}`, + value: `country-${index}`, + label: `کشور ${index + 1}`, + order: index + 1, + })), + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); + + expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus(); + expect(screen.getByRole("dialog").querySelector("section")).toHaveClass( + "h-[82svh]", + ); + }); + + it("sizes a short options sheet to its content", () => { + const question = { + id: "q_short", + title: "انتخاب کوتاه", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب" }, + options: Array.from({ length: 4 }, (_, index) => ({ + id: `option-${index}`, + value: `option-${index}`, + label: `گزینه ${index + 1}`, + order: index + 1, + })), + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "انتخاب" })); + + expect(screen.getByRole("dialog").querySelector("section")).toHaveClass( + "h-auto", + "max-h-[82svh]", + ); + }); }); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index bf8fc8d..86efa9d 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -37,7 +37,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { 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); @@ -76,6 +75,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { if (question.extras?.noSearch) return false; return options.length > 5; })(); + const isCompact = options.length <= 5; const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()), @@ -201,7 +201,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { >
@@ -260,7 +261,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { /> setSearchQuery(e.target.value)} diff --git a/src/components/Componentes/use-sheet-scroll-lock.ts b/src/components/Componentes/use-sheet-scroll-lock.ts index eb2b60f..2ff82a9 100644 --- a/src/components/Componentes/use-sheet-scroll-lock.ts +++ b/src/components/Componentes/use-sheet-scroll-lock.ts @@ -1,9 +1,10 @@ "use client"; import { useEffect } from "react"; +import { viewPaddingsBridge } from "@/lib/view-paddings"; let activeSheetCount = 0; -let activeKeyboardInputCount = 0; +let hasFocusedKeyboardInput = false; let bodyHadDropdownClass = false; let initialBodyOverflow = ""; let initialHtmlOverflow = ""; @@ -26,7 +27,7 @@ export function useSheetScrollLock(isOpen: boolean) { activeSheetCount += 1; document.body.classList.add("dropdown-open"); - document.body.classList.add("question-input-open"); + document.body.classList.add("question-sheet-open"); document.body.style.overflow = "hidden"; document.documentElement.style.overflow = "hidden"; if (lockedAppShell) { @@ -47,9 +48,7 @@ export function useSheetScrollLock(isOpen: boolean) { if (!bodyHadDropdownClass) { document.body.classList.remove("dropdown-open"); } - if (activeKeyboardInputCount === 0) { - document.body.classList.remove("question-input-open"); - } + document.body.classList.remove("question-sheet-open"); } }; }, [isOpen]); @@ -87,10 +86,10 @@ function isKeyboardInputTarget(target: EventTarget | null): boolean { } function syncQuestionInputOpenClass() { - if (activeSheetCount > 0 || activeKeyboardInputCount > 0) { - document.body.classList.add("question-input-open"); + if (hasFocusedKeyboardInput) { + document.body.classList.add("question-keyboard-open"); } else { - document.body.classList.remove("question-input-open"); + document.body.classList.remove("question-keyboard-open"); } } @@ -102,24 +101,52 @@ export function useQuestionInputFocusSync() { useEffect(() => { const handleFocusIn = (event: FocusEvent) => { if (!isKeyboardInputTarget(event.target)) return; - activeKeyboardInputCount += 1; + hasFocusedKeyboardInput = true; syncQuestionInputOpenClass(); }; const handleFocusOut = (event: FocusEvent) => { if (!isKeyboardInputTarget(event.target)) return; - activeKeyboardInputCount = Math.max(0, activeKeyboardInputCount - 1); + window.setTimeout(() => { + hasFocusedKeyboardInput = isKeyboardInputTarget(document.activeElement); + syncQuestionInputOpenClass(); + }, 0); + }; + + const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => { + if (config.keyboardHeight > 0) { + hasFocusedKeyboardInput = isKeyboardInputTarget(document.activeElement); + } else { + // Android Back can hide Flutter's keyboard without dispatching blur. + hasFocusedKeyboardInput = false; + } syncQuestionInputOpenClass(); + }); + + const initialViewportHeight = window.visualViewport?.height ?? 0; + const handleViewportResize = () => { + const viewportHeight = window.visualViewport?.height ?? 0; + if ( + initialViewportHeight > 0 && + viewportHeight >= initialViewportHeight - 40 + ) { + hasFocusedKeyboardInput = false; + syncQuestionInputOpenClass(); + } }; document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusout", handleFocusOut); + window.visualViewport?.addEventListener("resize", handleViewportResize); return () => { document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusout", handleFocusOut); - activeKeyboardInputCount = 0; - if (activeSheetCount === 0) { - document.body.classList.remove("question-input-open"); - } + window.visualViewport?.removeEventListener( + "resize", + handleViewportResize, + ); + unsubscribeConfig(); + hasFocusedKeyboardInput = false; + document.body.classList.remove("question-keyboard-open"); }; }, []); }