From 9c3d4ee5b41cce5ea25d9f6efc33e7cdb4e6b809 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Fri, 4 Sep 2026 23:05:02 +0330 Subject: [PATCH] feat: implement multi-select logic with exclusive option handling and add accompanying tests and UI component integration. --- src/app/sheet-lab/page.tsx | 93 +++++ .../Componentes/question-checkbox.tsx | 16 +- .../Componentes/question-dropdown.tsx | 17 +- src/components/Componentes/question-file.tsx | 4 +- .../Componentes/question-sheet.test.tsx | 138 ++++++- src/components/Componentes/question-sheet.tsx | 160 +++++++- .../Componentes/terms-sheet.test.tsx | 125 +++++++ src/components/Componentes/terms-sheet.tsx | 348 ++++++++++++++++-- src/hooks/marriage/use-form-schema.ts | 2 + src/lib/multi-select-helper.test.ts | 132 +++++++ src/lib/multi-select-helper.ts | 76 ++++ src/lib/schema-adapter.ts | 2 + 12 files changed, 1038 insertions(+), 75 deletions(-) create mode 100644 src/app/sheet-lab/page.tsx create mode 100644 src/components/Componentes/terms-sheet.test.tsx create mode 100644 src/lib/multi-select-helper.test.ts create mode 100644 src/lib/multi-select-helper.ts diff --git a/src/app/sheet-lab/page.tsx b/src/app/sheet-lab/page.tsx new file mode 100644 index 0000000..21fb55e --- /dev/null +++ b/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 ( + + + +
+

Sheet Lab

+ + +
+
+
+
+ ); +} diff --git a/src/components/Componentes/question-checkbox.tsx b/src/components/Componentes/question-checkbox.tsx index d2b7508..3dcd7f5 100644 --- a/src/components/Componentes/question-checkbox.tsx +++ b/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, diff --git a/src/components/Componentes/question-dropdown.tsx b/src/components/Componentes/question-dropdown.tsx index b7d30ab..d239368 100644 --- a/src/components/Componentes/question-dropdown.tsx +++ b/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, diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx index 35e7540..a4f5057 100644 --- a/src/components/Componentes/question-file.tsx +++ b/src/components/Componentes/question-file.tsx @@ -679,9 +679,7 @@ export function QuestionFile({ )} - {uploadProgress !== null && uploadProgress > 0 - ? `${t.uploading} (${uploadProgress}%)` - : t.uploading} + {t.uploading} )} diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx index 175b557..2dd0d90 100644 --- a/src/components/Componentes/question-sheet.test.tsx +++ b/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( + + + + + , + ); + + 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( + + + + + , + ); + + 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( + + + + + , + ); + + 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(); }); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index da943c6..433ff24 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/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(null); const sheetRef = useRef(null); + const sheetSizeRef = useRef(SHEET_INITIAL_SIZE); const [localSelectedList, setLocalSelectedList] = useState(selectedList); const localSelectedListRef = useRef(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 */}
event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()} onTouchEnd={(event) => event.stopPropagation()} diff --git a/src/components/Componentes/terms-sheet.test.tsx b/src/components/Componentes/terms-sheet.test.tsx new file mode 100644 index 0000000..a008db6 --- /dev/null +++ b/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(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders with initial 75% height when isOpen is true", () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const closeButton = screen.getByRole("button", { name: "بستن قوانین و مقررات" }); + fireEvent.click(closeButton); + + await waitFor(() => { + expect(handleClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/Componentes/terms-sheet.tsx b/src/components/Componentes/terms-sheet.tsx index 89c4a48..b5b0a47 100644 --- a/src/components/Componentes/terms-sheet.tsx +++ b/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(null); + const headerRef = useRef(null); + const contentRef = useRef(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 ( - ( - - - {termsTitle} - - - - )} - description={ -
+ 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( +
{ + if (event.target === event.currentTarget) closeSheet(); + }} + > +
+ {/* Drag handle & Header */} +
+
+
+
+ +
+ + {termsTitle} + + +
+
+ + {/* Scrollable Terms Content */} +
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) => (
-

- {(t as Record)[section.category] || section.category} +

+ {(t as Record)[section.category] || + section.category}

-
    +
      {section.items.map((item, itemIdx) => { const translatedTitle = item.title ? (t as Record)[item.title] || item.title @@ -266,11 +530,11 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) { return (
    • {translatedTitle && ( - + {translatedTitle}:{" "} )} - + {translatedDesc}
    • @@ -280,15 +544,21 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {

))}
- } - buttons={({ close }) => ( - - )} - onClose={onClose} - className="text-start" - /> + + {/* Bottom Confirm Action */} +
+ +
+
+
, + document.body, ); } diff --git a/src/hooks/marriage/use-form-schema.ts b/src/hooks/marriage/use-form-schema.ts index 6a83e85..94c38ae 100644 --- a/src/hooks/marriage/use-form-schema.ts +++ b/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 { diff --git a/src/lib/multi-select-helper.test.ts b/src/lib/multi-select-helper.test.ts new file mode 100644 index 0000000..9bc4eb1 --- /dev/null +++ b/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"]); + }); + }); +}); diff --git a/src/lib/multi-select-helper.ts b/src/lib/multi-select-helper.ts new file mode 100644 index 0000000..55f8dfe --- /dev/null +++ b/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]; +} diff --git a/src/lib/schema-adapter.ts b/src/lib/schema-adapter.ts index be7366c..a2a8729 100644 --- a/src/lib/schema-adapter.ts +++ b/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), })), }; }