Browse Source

fix sheet

Dev
mortezaei 1 week ago
parent
commit
c7256e87fa
  1. 34
      src/app/globals.css
  2. 56
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  3. 21
      src/app/questions-list/[slug]/question-detail-client.tsx
  4. 23
      src/app/questions-list/page.tsx
  5. 12
      src/components/Componentes/question-birthplace.tsx
  6. 2
      src/components/Componentes/question-progress-tracker.tsx
  7. 73
      src/components/Componentes/question-sheet.test.tsx
  8. 6
      src/components/Componentes/question-sheet.tsx
  9. 55
      src/components/Componentes/use-sheet-scroll-lock.ts

34
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 {

56
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(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug="profile_test"
questionsListHref="/questions-list"
title="Route Fallback Title"
/>
</QueryClientProvider>,
);
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({

21
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 (
<PageLoadingSkeleton compact variant="test" />
);
return <PageLoadingSkeleton compact variant="test" />;
} else if (!item) {
return null;
}

23
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<string>(
@ -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);

12
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<HTMLInputElement>(null);
const cityInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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({
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}

2
src/components/Componentes/question-progress-tracker.tsx

@ -171,7 +171,7 @@ export function QuestionProgressTracker({
onChange={updateProgress}
onInput={updateProgress}
>
<div className="w-full shrink-0 px-[17px] pt-2 pb-[24px]">
<div className="question-progress w-full shrink-0 px-[17px] pt-2 pb-[24px]">
<div
aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={safeTotal}

73
src/components/Componentes/question-sheet.test.tsx

@ -31,7 +31,8 @@ describe("QuestionSheet component", () => {
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
expect(screen.getByRole("dialog").querySelector("section")).toHaveClass(
"h-auto",
"max-h-[82svh]",
);
});
});

6
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<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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) {
>
<section
className={[
"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",
"flex 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",
isCompact ? "h-auto max-h-[82svh]" : "h-[82svh]",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
@ -260,7 +261,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}

55
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");
};
}, []);
}
Loading…
Cancel
Save