Browse Source

feat: implement multi-select logic with exclusive option handling and add accompanying tests and UI component integration.

staging
mortezaei 2 weeks ago
parent
commit
9c3d4ee5b4
  1. 93
      src/app/sheet-lab/page.tsx
  2. 16
      src/components/Componentes/question-checkbox.tsx
  3. 17
      src/components/Componentes/question-dropdown.tsx
  4. 4
      src/components/Componentes/question-file.tsx
  5. 138
      src/components/Componentes/question-sheet.test.tsx
  6. 160
      src/components/Componentes/question-sheet.tsx
  7. 125
      src/components/Componentes/terms-sheet.test.tsx
  8. 348
      src/components/Componentes/terms-sheet.tsx
  9. 2
      src/hooks/marriage/use-form-schema.ts
  10. 132
      src/lib/multi-select-helper.test.ts
  11. 76
      src/lib/multi-select-helper.ts
  12. 2
      src/lib/schema-adapter.ts

93
src/app/sheet-lab/page.tsx

@ -0,0 +1,93 @@
"use client";
/**
* Standalone dev page for testing QuestionSheet sizing behavior in a browser.
* No login, no backend: profile query data is seeded into the QueryClient so
* QuestionAnswersProvider never needs the API. Hardcoded questions only.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import QuestionSheet from "@/components/Componentes/question-sheet";
import { QuestionAnswersProvider } from "@/components/Componentes/question-answer-storage";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import type { QuestionField } from "@/lib/schema-adapter";
import { I18nProvider } from "@/translations/provider";
function makeOptions(count: number, prefix: string): QuestionField["options"] {
return Array.from({ length: count }, (_, index) => ({
id: `${prefix}-${index + 1}`,
value: `${prefix}-${index + 1}`,
label: `${prefix} ${index + 1}`,
order: index + 1,
}));
}
const fourOptionQuestion: QuestionField = {
id: "sheet_lab.four_options",
title: "Sheet Lab — 4 options (compact)",
type: "dropdown",
order: 1,
required: false,
baseRequired: false,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "Select (4)", range: [0, 1], options: [] },
options: makeOptions(4, "Option"),
ui_config: {},
};
const manyOptionQuestion: QuestionField = {
id: "sheet_lab.many_options",
title: "Sheet Lab — 67 options (draggable)",
type: "dropdown",
order: 2,
required: false,
baseRequired: false,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "Select (67)", range: [0, 1], options: [] },
options: makeOptions(67, "Item"),
ui_config: {},
};
export default function SheetLabPage() {
const [queryClient] = useState(() => {
const qc = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: Infinity,
},
},
});
// Seed the profile cache so QuestionAnswersProvider never hits the API.
qc.setQueryData(marriageQueryKeys.profile(), {
id: 1,
can_edit_profile: true,
});
return qc;
});
return (
<I18nProvider locale="en">
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="sheet-lab"
questions={[fourOptionQuestion, manyOptionQuestion]}
>
<main className="mx-auto flex max-w-[480px] flex-col gap-8 p-4 pt-10">
<h1 className="text-lg font-bold">Sheet Lab</h1>
<QuestionSheet question={fourOptionQuestion} />
<QuestionSheet question={manyOptionQuestion} />
</main>
</QuestionAnswersProvider>
</QueryClientProvider>
</I18nProvider>
);
}

16
src/components/Componentes/question-checkbox.tsx

@ -1,5 +1,6 @@
"use client";
import { resolveMultiOptionToggle } from "@/lib/multi-select-helper";
import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
@ -105,15 +106,12 @@ export function QuestionCheckbox({
(question.validation?.max ? Number(question.validation.max) : undefined);
const toggleOption = (optionId: string) => {
let nextValue: string[];
if (value.includes(optionId)) {
nextValue = value.filter((v) => v !== optionId);
} else {
if (maxSelect && value.length >= maxSelect) {
return;
}
nextValue = [...value, optionId];
}
const nextValue = resolveMultiOptionToggle({
currentSelected: value,
optionId,
options,
maxSelect,
});
setAnswerValue(
question, nextValue.length > 0 ? nextValue : null,

17
src/components/Componentes/question-dropdown.tsx

@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Ic } from "@/icons";
import { ExplanationUiFont } from "./explanation-ui-font";
import { resolveMultiOptionToggle } from "@/lib/multi-select-helper";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
@ -136,16 +137,12 @@ export function QuestionDropdown({
: Boolean(singleValue);
const toggleMultiOption = (optionId: string) => {
let nextValue: string[];
if (selectedList.includes(optionId)) {
nextValue = selectedList.filter((v) => v !== optionId);
} else {
if (maxSelect && selectedList.length >= maxSelect) {
return;
}
nextValue = [...selectedList, optionId];
}
const nextValue = resolveMultiOptionToggle({
currentSelected: selectedList,
optionId,
options,
maxSelect,
});
setAnswerValue(
question, nextValue.length > 0 ? nextValue : null,

4
src/components/Componentes/question-file.tsx

@ -679,9 +679,7 @@ export function QuestionFile({
)}
</div>
<span className="text-xs font-semibold text-white">
{uploadProgress !== null && uploadProgress > 0
? `${t.uploading} (${uploadProgress}%)`
: t.uploading}
{t.uploading}
</span>
</div>
)}

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

@ -252,11 +252,141 @@ describe("QuestionSheet component", () => {
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus();
expect(screen.getByRole("dialog").querySelector("section")).toHaveClass(
"h-[82svh]",
expect(
screen.getByRole("dialog").querySelector("section"),
).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]");
});
it("grows the sheet when an overflowing list is pulled past its top", () => {
const question = {
id: "q_grow",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, 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 }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
Object.defineProperty(list, "scrollHeight", {
value: 800,
configurable: true,
});
Object.defineProperty(list, "clientHeight", {
value: 400,
configurable: true,
});
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 300 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 100 / window.innerHeight).toFixed(4),
);
});
it("grows the sheet on wheel overscroll at the top of the list", () => {
const question = {
id: "q_wheel",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, 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 }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
Object.defineProperty(list, "scrollHeight", {
value: 800,
configurable: true,
});
Object.defineProperty(list, "clientHeight", {
value: 400,
configurable: true,
});
fireEvent.wheel(list, { deltaY: 120 });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 120 / window.innerHeight).toFixed(4),
);
});
it("shrinks to the 60% floor and closes when dragged past it", async () => {
const question = {
id: "q_shrink",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, 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 }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
// Pull down at the top: sheet shrinks and clamps at the 0.6 floor.
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 560 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.6000");
// shouldCloseOnMinExtent: dragging below the floor closes the sheet.
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 580 }] });
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("sizes a short options sheet to its content (4 options)", () => {
const question = {
id: "q_short",
@ -348,7 +478,7 @@ describe("QuestionSheet component", () => {
const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-auto", "max-h-[82svh]");
expect(section).not.toHaveClass("h-[82svh]");
expect(section).not.toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]");
expect(screen.queryByPlaceholderText("جستجو...")).toBeNull();
});
@ -379,7 +509,7 @@ describe("QuestionSheet component", () => {
fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-[82svh]");
expect(section).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]");
expect(section).not.toHaveClass("h-auto");
expect(screen.getByPlaceholderText("جستجو...")).toBeDefined();
});

160
src/components/Componentes/question-sheet.tsx

@ -15,6 +15,7 @@ import {
resolveLanguageName,
getLanguageSearchKeywords,
} from "@/data/languages";
import { resolveMultiOptionToggle } from "@/lib/multi-select-helper";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { Button } from "./button";
@ -28,6 +29,15 @@ const EXIT_ANIMATION_MS = 200;
const EMPTY_ARRAY: string[] = [];
const COMPACT_OPTIONS_MAX = 6;
// Parity with najm's DraggableScrollableSheet language sheet
// (initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0 implicit,
// shouldCloseOnMinExtent: true): the sheet opens at 75% of the screen, grows
// while the option list is pulled past its top, and closes below 60%.
const SHEET_INITIAL_SIZE = 0.75;
const SHEET_MIN_SIZE = 0.6;
const SHEET_MAX_SIZE = 1;
const GESTURE_ENGAGE_PX = 10;
export type QuestionSheetProps = {
question: QuestionField;
disabled?: boolean;
@ -64,6 +74,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList);
const localSelectedListRef = useRef<string[]>(selectedList);
@ -100,6 +111,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const openSheet = useCallback(() => {
if (disabled) return;
sheetSizeRef.current = SHEET_INITIAL_SIZE;
setLocalSelectedList(selectedList);
localSelectedListRef.current = selectedList;
setIsOpen(true);
@ -327,6 +339,136 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
};
}, [isCompact, isClosing, isOpen]);
// Drag-to-resize parity with najm's DraggableScrollableSheet: pulling the
// option list past its top grows the sheet itself (continuous, no snapping);
// pulling down at the top shrinks it, and crossing the 60% floor closes it.
// Height is applied imperatively via --sheet-size so gestures never trigger
// React re-renders of the (potentially hundreds of) option rows.
useEffect(() => {
if (!isOpen || isClosing || !showSearch) return;
const list = listRef.current;
const sheet = sheetRef.current;
if (!list || !sheet) return;
let active = false;
let engaged = false;
let closing = false;
let startX = 0;
let startY = 0;
let lastY = 0;
const listOverflows = () => list.scrollHeight > list.clientHeight + 1;
const resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1;
const nextSize = Math.min(
SHEET_MAX_SIZE,
Math.max(
SHEET_MIN_SIZE,
sheetSizeRef.current + deltaPx / viewportHeight,
),
);
sheetSizeRef.current = nextSize;
sheet.style.setProperty("--sheet-size", nextSize.toFixed(4));
};
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
active = false;
return;
}
active = true;
engaged = false;
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
lastY = startY;
};
const handleTouchMove = (event: TouchEvent) => {
if (!active || closing || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaY = lastY - touch.clientY; // finger up = pull for more content
const pulled = startY - touch.clientY; // cumulative finger-up distance
lastY = touch.clientY;
if (!engaged) {
// Commit to the gesture only after a clear vertical drag; calling
// preventDefault on micro-movements kills the synthetic tap on options.
if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return;
if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return;
const atTop = list.scrollTop <= 0;
const canGrow =
pulled > 0 &&
atTop &&
listOverflows() &&
sheetSizeRef.current < SHEET_MAX_SIZE - 0.001;
const canShrink = pulled < 0 && atTop;
if (!canGrow && !canShrink) return;
engaged = true;
}
const atTop = list.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume =
deltaY > 0
? atTop && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return; // sheet is at its ceiling — the list scrolls
event.preventDefault();
if (deltaY > 0) {
resize(deltaY);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true; // shouldCloseOnMinExtent: true
closeSheet();
} else {
resize(deltaY);
}
};
const handleTouchEnd = () => {
active = false;
engaged = false;
};
const handleWheel = (event: WheelEvent) => {
if (closing) return;
const delta = event.deltaY;
const atTop = list.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume =
delta > 0
? atTop && listOverflows() && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return;
event.preventDefault();
if (delta > 0) {
resize(delta);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true;
closeSheet();
} else {
resize(delta);
}
};
list.addEventListener("touchstart", handleTouchStart, { passive: true });
list.addEventListener("touchmove", handleTouchMove, { passive: false });
list.addEventListener("touchend", handleTouchEnd, { passive: true });
list.addEventListener("touchcancel", handleTouchEnd, { passive: true });
list.addEventListener("wheel", handleWheel, { passive: false });
return () => {
list.removeEventListener("touchstart", handleTouchStart);
list.removeEventListener("touchmove", handleTouchMove);
list.removeEventListener("touchend", handleTouchEnd);
list.removeEventListener("touchcancel", handleTouchEnd);
list.removeEventListener("wheel", handleWheel);
};
}, [isOpen, isClosing, showSearch, closeSheet]);
const filteredOptions = useMemo(() => {
const rawQ = searchQuery.trim();
if (!rawQ) return options;
@ -396,15 +538,12 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const toggleMultiOption = (optionId: string) => {
setLocalSelectedList((prev) => {
let nextValue: string[];
if (prev.includes(optionId)) {
nextValue = prev.filter((v) => v !== optionId);
} else {
if (maxSelect && prev.length >= maxSelect) {
return prev;
}
nextValue = [...prev, optionId];
}
const nextValue = resolveMultiOptionToggle({
currentSelected: prev,
optionId,
options,
maxSelect,
});
localSelectedListRef.current = nextValue;
return nextValue;
});
@ -529,7 +668,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
"flex w-full max-w-[834px] sm:max-w-[540px] 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)]",
!showSearch
? "h-auto max-h-[82svh]"
: "h-[82svh] min-h-[82svh] max-h-[82svh]",
: "h-[calc(var(--sheet-size,0.75)*100svh)]",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
].join(" ")}
>
@ -641,6 +780,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
{/* Options List */}
<div
ref={listRef}
data-testid="question-sheet-list"
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}

125
src/components/Componentes/terms-sheet.test.tsx

@ -0,0 +1,125 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import TermsSheet from "./terms-sheet";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "fa",
dictionary: {
"terms & conditions": "قوانین و مقررات",
"Got it": "متوجه شدم",
"Close terms and conditions": "بستن قوانین و مقررات",
},
}),
}));
describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it("does not render when isOpen is false", () => {
render(<TermsSheet isOpen={false} />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders with initial 75% height when isOpen is true", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
const section = dialog.querySelector("section")!;
expect(section).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]");
expect(screen.getByText("قوانین و مقررات")).toBeInTheDocument();
expect(screen.getByText("متوجه شدم")).toBeInTheDocument();
});
it("grows sheet when pulling up at top of content", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 100 / window.innerHeight).toFixed(4),
);
});
it("grows sheet on wheel event at top of content", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
fireEvent.wheel(content, { deltaY: 120 });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 120 / window.innerHeight).toFixed(4),
);
});
it("shrinks to 60% floor and closes when pulled past it (shouldCloseOnMinExtent: true)", async () => {
const handleClose = vi.fn();
render(<TermsSheet isOpen={true} onClose={handleClose} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
// Pull down at the top: sheet shrinks and clamps at 0.6 floor
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 560 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.6000");
// Pull further past 0.6: triggers close
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 580 }] });
await waitFor(() => {
expect(handleClose).toHaveBeenCalled();
});
});
it("calls onClose when Got it button is clicked", async () => {
const handleClose = vi.fn();
render(<TermsSheet isOpen={true} onClose={handleClose} />);
const gotItButton = screen.getByRole("button", { name: "متوجه شدم" });
fireEvent.click(gotItButton);
await waitFor(() => {
expect(handleClose).toHaveBeenCalled();
});
});
it("calls onClose when close X button is clicked", async () => {
const handleClose = vi.fn();
render(<TermsSheet isOpen={true} onClose={handleClose} />);
const closeButton = screen.getByRole("button", { name: "بستن قوانین و مقررات" });
fireEvent.click(closeButton);
await waitFor(() => {
expect(handleClose).toHaveBeenCalled();
});
});
});

348
src/components/Componentes/terms-sheet.tsx

@ -1,9 +1,11 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Ic } from "@/icons";
import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet";
import { useI18n } from "@/translations/provider";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
export type TermItem = {
title?: string;
@ -217,45 +219,307 @@ export const TERMS_SECTIONS: TermSection[] = [
},
];
const EXIT_ANIMATION_MS = 200;
// Parity with Flutter DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
const SHEET_INITIAL_SIZE = 0.75;
const SHEET_MIN_SIZE = 0.6;
const SHEET_MAX_SIZE = 1;
const GESTURE_ENGAGE_PX = 10;
export type TermsSheetProps = {
isOpen?: boolean;
onClose?: () => void;
};
export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
const { dictionary: t } = useI18n();
export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
const { dictionary: t, locale } = useI18n();
const [mounted, setMounted] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const sheetRef = useRef<HTMLElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
const termsTitle = t["terms & conditions"] || "Terms & Conditions";
const gotItLabel = t["Got it"] || "Got it";
const closeAriaLabel =
t["Close terms and conditions"] || "Close terms and conditions";
return (
<InformationSheet
isOpen={Boolean(isOpen)}
icon={null}
showCloseButton={false}
title={({ close }) => (
<span className="flex w-full items-start justify-between gap-3 text-start">
<span className="text-[14px] leading-5 font-bold tracking-normal text-[#8B8B8B]">
{termsTitle}
</span>
<button
type="button"
aria-label={t["Close terms and conditions"] || "Close terms and conditions"}
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F] cursor-pointer"
onClick={close}
>
<Ic name="close" aria-hidden="true" className="size-5" />
</button>
</span>
)}
description={
<div className="h-[56dvh] max-h-[60dvh] space-y-4 overflow-y-auto pr-1 text-start text-[12px] leading-[1.45] text-[#4C4C4C]">
useEffect(() => {
setMounted(true);
}, []);
const closeSheet = useCallback(() => {
if (isClosingRef.current) return;
isClosingRef.current = true;
setIsClosing(true);
window.setTimeout(() => {
setIsClosing(false);
isClosingRef.current = false;
onClose?.();
}, EXIT_ANIMATION_MS);
}, [onClose]);
useEffect(() => {
if (isOpen && mounted) {
isClosingRef.current = false;
setIsClosing(false);
sheetSizeRef.current = SHEET_INITIAL_SIZE;
if (sheetRef.current) {
sheetRef.current.style.setProperty(
"--sheet-size",
SHEET_INITIAL_SIZE.toFixed(4),
);
}
}
}, [isOpen, mounted]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
// Drag-to-resize parity with Flutter DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
useEffect(() => {
if (!isOpen || isClosing) return;
const content = contentRef.current;
const sheet = sheetRef.current;
const header = headerRef.current;
if (!sheet) return;
let active = false;
let engaged = false;
let closing = false;
let isHeaderDrag = false;
let startX = 0;
let startY = 0;
let lastY = 0;
const contentOverflows = () =>
content ? content.scrollHeight > content.clientHeight + 1 : false;
const resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1;
const nextSize = Math.min(
SHEET_MAX_SIZE,
Math.max(
SHEET_MIN_SIZE,
sheetSizeRef.current + deltaPx / viewportHeight,
),
);
sheetSizeRef.current = nextSize;
sheet.style.setProperty("--sheet-size", nextSize.toFixed(4));
};
const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => {
if (event.touches.length !== 1) {
active = false;
return;
}
active = true;
engaged = fromHeader;
isHeaderDrag = fromHeader;
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
lastY = startY;
};
const handleTouchMove = (event: TouchEvent) => {
if (!active || closing || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaY = lastY - touch.clientY; // finger up = grow sheet
const pulled = startY - touch.clientY; // cumulative distance
lastY = touch.clientY;
if (!engaged) {
if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return;
if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return;
const atTop = !content || content.scrollTop <= 0;
const canGrow =
pulled > 0 &&
atTop &&
contentOverflows() &&
sheetSizeRef.current < SHEET_MAX_SIZE - 0.001;
const canShrink = pulled < 0 && atTop;
if (!canGrow && !canShrink) return;
engaged = true;
}
const atTop = !content || content.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume = isHeaderDrag
? true
: deltaY > 0
? atTop && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return; // at ceiling or scrolling inside content
if (event.cancelable) {
event.preventDefault();
}
if (deltaY > 0) {
resize(deltaY);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true; // shouldCloseOnMinExtent: true
closeSheet();
} else {
resize(deltaY);
}
};
const handleTouchEnd = () => {
active = false;
engaged = false;
isHeaderDrag = false;
};
const handleWheel = (event: WheelEvent) => {
if (closing) return;
const delta = event.deltaY;
const atTop = !content || content.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume =
delta > 0
? atTop && contentOverflows() && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return;
if (event.cancelable) {
event.preventDefault();
}
if (delta > 0) {
resize(delta);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true;
closeSheet();
} else {
resize(delta);
}
};
const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
const onContentTouchStart = (e: TouchEvent) => handleTouchStart(e, false);
if (header) {
header.addEventListener("touchstart", onHeaderTouchStart, {
passive: true,
});
}
if (content) {
content.addEventListener("touchstart", onContentTouchStart, {
passive: true,
});
content.addEventListener("touchmove", handleTouchMove, { passive: false });
content.addEventListener("touchend", handleTouchEnd, { passive: true });
content.addEventListener("touchcancel", handleTouchEnd, { passive: true });
content.addEventListener("wheel", handleWheel, { passive: false });
}
sheet.addEventListener("touchmove", handleTouchMove, { passive: false });
sheet.addEventListener("touchend", handleTouchEnd, { passive: true });
sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
if (header) {
header.removeEventListener("touchstart", onHeaderTouchStart);
}
if (content) {
content.removeEventListener("touchstart", onContentTouchStart);
content.removeEventListener("touchmove", handleTouchMove);
content.removeEventListener("touchend", handleTouchEnd);
content.removeEventListener("touchcancel", handleTouchEnd);
content.removeEventListener("wheel", handleWheel);
}
sheet.removeEventListener("touchmove", handleTouchMove);
sheet.removeEventListener("touchend", handleTouchEnd);
sheet.removeEventListener("touchcancel", handleTouchEnd);
};
}, [isOpen, isClosing, mounted, closeSheet]);
if (!mounted || (!isOpen && !isClosing)) return null;
const isRtl =
locale === "fa" ||
locale === "ar" ||
locale === "ur" ||
locale === "he" ||
locale === "ks";
return createPortal(
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center",
isClosing ? "flutter-scrim-exit" : "flutter-scrim",
].join(" ")}
role="dialog"
aria-modal="true"
aria-label={termsTitle}
dir={isRtl ? "rtl" : "ltr"}
onClick={(event) => {
if (event.target === event.currentTarget) closeSheet();
}}
>
<section
ref={sheetRef}
className={[
"flex w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white shadow-[0_-10px_32px_rgba(0,0,0,0.12)]",
"h-[calc(var(--sheet-size,0.75)*100svh)]",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
].join(" ")}
>
{/* Drag handle & Header */}
<div ref={headerRef} className="flex flex-col select-none touch-none">
<div className="flex justify-center pt-2.5 pb-1 cursor-grab active:cursor-grabbing">
<div className="h-1.2 w-10 rounded-full bg-[#D0D5DD]" />
</div>
<div className="flex items-center justify-between gap-3 px-5 pt-1 pb-3 border-b border-[#F2F4F7]">
<span className="text-[16px] font-bold text-[#181818]">
{termsTitle}
</span>
<button
type="button"
aria-label={closeAriaLabel}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer border-none bg-transparent"
onClick={closeSheet}
>
<Ic name="close" aria-hidden="true" className="size-5" />
</button>
</div>
</div>
{/* Scrollable Terms Content */}
<div
ref={contentRef}
data-testid="terms-sheet-content"
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain px-5 py-4 space-y-4 text-start text-[13px] leading-[1.6] text-[#4C4C4C]"
>
{TERMS_SECTIONS.map((section, idx) => (
<div key={idx} className="space-y-2">
<h4 className="text-[13px] font-bold text-[#F14B46]">
{(t as Record<string, string>)[section.category] || section.category}
<h4 className="text-[14px] font-bold text-[#F14B46]">
{(t as Record<string, string>)[section.category] ||
section.category}
</h4>
<ul className="space-y-2 list-disc ps-4 marker:text-[#2B2B2B]">
<ul className="space-y-2.5 list-disc ps-4 marker:text-[#2B2B2B]">
{section.items.map((item, itemIdx) => {
const translatedTitle = item.title
? (t as Record<string, string>)[item.title] || item.title
@ -266,11 +530,11 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
return (
<li key={itemIdx} className="leading-relaxed">
{translatedTitle && (
<span className="font-bold text-[#262626]">
<span className="font-bold text-[#181818]">
{translatedTitle}:{" "}
</span>
)}
<span className="font-normal text-[#525252]">
<span className="font-normal text-[#475467]">
{translatedDesc}
</span>
</li>
@ -280,15 +544,21 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
</div>
))}
</div>
}
buttons={({ close }) => (
<Button className="rounded-[8px]" onClick={close}>
{gotItLabel}
</Button>
)}
onClose={onClose}
className="text-start"
/>
{/* Bottom Confirm Action */}
<div className="border-t border-[#F2F4F7] bg-white p-4 pt-3 pb-[calc(14px+env(safe-area-inset-bottom))]">
<Button
type="button"
variant="default"
onClick={closeSheet}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)] cursor-pointer"
>
{gotItLabel}
</Button>
</div>
</section>
</div>,
document.body,
);
}

2
src/hooks/marriage/use-form-schema.ts

@ -10,6 +10,8 @@ export interface FormOption {
value: string;
label: string;
order: number;
/** مانعةالجمع — از دیتابیس (ui_config.exclusive_options) توسط بک‌اند محاسبه می‌شود */
is_exclusive?: boolean;
}
export interface FormQuestion {

132
src/lib/multi-select-helper.test.ts

@ -0,0 +1,132 @@
import { describe, it, expect } from "vitest";
import {
isExclusiveOption,
resolveMultiOptionToggle,
type MultiSelectOption,
} from "./multi-select-helper";
describe("multi-select-helper", () => {
const options: MultiSelectOption[] = [
{ id: "opt1", value: "football", label: "Football" },
{ id: "opt2", value: "swimming", label: "Swimming" },
{ id: "opt3", value: "running", label: "Running" },
{ id: "opt_none", value: "none", label: "None of the above", is_exclusive: true },
];
describe("isExclusiveOption", () => {
it("identifies explicit is_exclusive property", () => {
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true);
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false);
});
it("accepts the explicit flag and rejects everything else (SSOT: backend flag only)", () => {
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true);
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false);
expect(isExclusiveOption({ id: "custom" })).toBe(false);
});
it("returns false for missing/empty/string-only input (no flag = not exclusive)", () => {
expect(isExclusiveOption("")).toBe(false);
expect(isExclusiveOption("test.none")).toBe(false);
});
it("does NOT guess from canonical values or slug suffixes (SSOT: backend flag only)", () => {
expect(isExclusiveOption({ id: "test.none", value: "none" })).toBe(false);
expect(isExclusiveOption({ id: "test.no_pets", value: "no_pets" })).toBe(false);
expect(
isExclusiveOption(
"spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult",
),
).toBe(false);
expect(
isExclusiveOption({
id: "spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult",
value: "no_appearance_feature_alone_makes_difficult",
}),
).toBe(false);
});
});
describe("resolveMultiOptionToggle", () => {
it("adds a regular option when none was selected", () => {
const res = resolveMultiOptionToggle({
currentSelected: [],
optionId: "opt1",
options,
});
expect(res).toEqual(["opt1"]);
});
it("adds multiple regular options sequentially", () => {
const res1 = resolveMultiOptionToggle({
currentSelected: ["opt1"],
optionId: "opt2",
options,
});
expect(res1).toEqual(["opt1", "opt2"]);
const res2 = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt3",
options,
});
expect(res2).toEqual(["opt1", "opt2", "opt3"]);
});
it("deselects a regular option when toggled again", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt1",
options,
});
expect(res).toEqual(["opt2"]);
});
it("clears ALL regular options when an exclusive option is selected", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2", "opt3"],
optionId: "opt_none",
options,
});
expect(res).toEqual(["opt_none"]);
});
it("deselects the exclusive option when toggled again", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt_none"],
optionId: "opt_none",
options,
});
expect(res).toEqual([]);
});
it("clears the exclusive option when a regular option is clicked", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt_none"],
optionId: "opt1",
options,
});
expect(res).toEqual(["opt1"]);
});
it("enforces maxSelect for regular options without exclusive conflict", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt3",
options,
maxSelect: 2,
});
expect(res).toEqual(["opt1", "opt2"]); // Capped at 2
});
it("allows selecting exclusive option even when regular options reach maxSelect", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt_none",
options,
maxSelect: 2,
});
expect(res).toEqual(["opt_none"]);
});
});
});

76
src/lib/multi-select-helper.ts

@ -0,0 +1,76 @@
export type MultiSelectOption = {
id: string;
value?: string | number;
label?: string;
is_exclusive?: boolean;
};
/**
* Determine if an option is mutually exclusive (مانعةالجمع) with the other
* options of its question.
*
* Single Source of Truth: exclusivity is configured in the live database
* (via the admin dashboard) and delivered by the backend as the
* `is_exclusive` flag on every option. No hardcoded slug/suffix heuristics.
*/
export function isExclusiveOption(option: MultiSelectOption | string): boolean {
if (!option || typeof option !== "object") return false;
return option.is_exclusive === true;
}
/**
* Resolves toggling an option in a multi-select context with automatic mutual exclusion (مانعةالجمع).
*
* Behavior:
* 1. If an exclusive option is selected:
* - All other selected options are automatically cleared (deselected).
* - Only the exclusive option remains selected.
* 2. If an exclusive option is deselected:
* - It is removed, leaving an empty selection.
* 3. If a regular (non-exclusive) option is selected while an exclusive option was active:
* - The exclusive option is automatically cleared (deselected).
* - The new regular option is selected.
* 4. Respects maxSelect limit for regular options.
*/
export function resolveMultiOptionToggle({
currentSelected,
optionId,
options = [],
maxSelect,
}: {
currentSelected: string[];
optionId: string;
options?: MultiSelectOption[];
maxSelect?: number;
}): string[] {
const targetOption = options.find((o) => o.id === optionId) || { id: optionId };
const isExclusive = isExclusiveOption(targetOption);
const isAlreadySelected = currentSelected.includes(optionId);
// Case 1: Toggling an exclusive option
if (isExclusive) {
if (isAlreadySelected) {
return [];
}
return [optionId];
}
// Case 2: Toggling a regular option that is already selected -> remove it
if (isAlreadySelected) {
return currentSelected.filter((id) => id !== optionId);
}
// Case 3: Adding a regular option that was NOT selected:
// First, filter out any exclusive option(s) from current selection
const cleanSelected = currentSelected.filter((id) => {
const opt = options.find((o) => o.id === id) || { id };
return !isExclusiveOption(opt);
});
// Check maxSelect limit
if (maxSelect && cleanSelected.length >= maxSelect) {
return cleanSelected;
}
return [...cleanSelected, optionId];
}

2
src/lib/schema-adapter.ts

@ -142,6 +142,7 @@ export type QuestionField = {
value: string | number;
label: string;
order: number;
is_exclusive?: boolean;
}[];
};
@ -274,6 +275,7 @@ export function mapBackendQuestionToFrontend(
.map((o) => ({
...o,
label: o.label || o.value || "",
is_exclusive: Boolean(o.is_exclusive),
})),
};
}

Loading…
Cancel
Save