diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx index 9697931..e001e88 100644 --- a/src/components/Componentes/question-sheet.test.tsx +++ b/src/components/Componentes/question-sheet.test.tsx @@ -9,7 +9,11 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import type { QuestionField } from "@/lib/schema-adapter"; import { QuestionAnswersProvider } from "./question-answer-storage"; -import { QuestionSheet } from "./question-sheet"; +import { + QuestionSheet, + calculateOptionMinHeight, + estimateOptionLines, +} from "./question-sheet"; vi.mock("@/translations/provider", () => ({ useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })), @@ -1099,6 +1103,86 @@ describe("QuestionSheet component", () => { expect(screen.getByRole("button", { name: "Albania" })).toBeDefined(); expect(screen.queryByRole("button", { name: "Afghanistan" })).toBeNull(); }); + + describe("Character-based option height calculation & border enclosure", () => { + it("estimates lines based on character count and word wrapping", () => { + expect(estimateOptionLines("")).toBe(1); + expect(estimateOptionLines("گزینه کوتاه")).toBe(1); + // 55 characters should take at least 2 lines (at 28 chars/line) + expect( + estimateOptionLines("این یک متن دو خطی برای تست گزینههای بلند در شیت سوال است"), + ).toBeGreaterThanOrEqual(2); + // 115 characters (from user screenshot) should take at least 4 lines + const longText = + "همسر آیندهام اهل مشارکت فعال در امور عامالمنفعه، خیریه و فعالیتهای داوطلبانه و عامالمنفعه باشد"; + expect(estimateOptionLines(longText, 28)).toBeGreaterThanOrEqual(4); + }); + + it("calculates minimum height dynamically according to text character length", () => { + // 1 line text defaults to standard minimum of 52px + expect(calculateOptionMinHeight("گزینه کوتاه")).toBe(52); + + // Long text (115 chars) dynamically calculates >= 114px to enclose all lines + const longText = + "همسر آیندهام اهل مشارکت فعال در امور عامالمنفعه، خیریه و فعالیتهای داوطلبانه و عامالمنفعه باشد"; + const calculatedHeight = calculateOptionMinHeight(longText); + expect(calculatedHeight).toBeGreaterThanOrEqual(114); + + // Compound options with " - " separator calculate height for both title and description + const compoundText = + "عنوان بلند گزینه - توضیحات تکمیلی درباره این گزینه که چند سطر متن دارد"; + const compoundHeight = calculateOptionMinHeight(compoundText); + expect(compoundHeight).toBeGreaterThanOrEqual(74); + }); + + it("applies dynamic min-height to option card elements to prevent text overflow outside borders", () => { + const longText = + "همسر آیندهام اهل مشارکت فعال در امور عامالمنفعه، خیریه و فعالیتهای داوطلبانه و عامالمنفعه باشد"; + + const longQuestion = { + id: "volunteer_work_preference", + title: "دیدگاه شما در مورد مشارکت در امور خیریه", + type: "dropdown", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "انتخاب کنید" }, + options: [ + { id: "short_opt", value: "short", label: "موافق", order: 1 }, + { id: "long_opt", value: "long", label: longText, order: 2 }, + ], + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "انتخاب کنید" })); + + const shortBtn = screen.getByRole("button", { name: "موافق" }); + const longBtn = screen.getByRole("button", { name: longText }); + + // Short button has baseline min-height: 52px + expect(shortBtn.style.minHeight).toBe("52px"); + + // Long button dynamically scales based on character count (>= 114px) + const expectedMinHeight = `${calculateOptionMinHeight(longText)}px`; + expect(longBtn.style.minHeight).toBe(expectedMinHeight); + expect(calculateOptionMinHeight(longText)).toBeGreaterThanOrEqual(114); + + // Card container has h-auto and shrink-0 to prevent flex clamping + expect(longBtn).toHaveClass("h-auto"); + expect(longBtn).toHaveClass("shrink-0"); + expect(longBtn).toHaveClass("box-border"); + }); + }); }); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index ac6405a..53d3c7d 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -38,6 +38,52 @@ const SHEET_MIN_SIZE = 0.6; const SHEET_MAX_SIZE = 1; const GESTURE_ENGAGE_PX = 10; +/** + * Character-based dynamic height algorithm for option cards in question sheets. + * Calculates the required minimum height based on character count and word wrapping, + * ensuring the card border completely encloses all lines of text without overflow or clipping. + */ +export function estimateOptionLines(text: string, charsPerLine: number = 28): number { + if (!text) return 1; + const clean = text.trim(); + if (clean.length === 0) return 1; + const words = clean.split(/\s+/); + let lines = 1; + let currentLen = 0; + for (const word of words) { + if (currentLen + word.length > charsPerLine && currentLen > 0) { + lines++; + currentLen = word.length; + } else { + currentLen += (currentLen === 0 ? 0 : 1) + word.length; + } + } + const charLines = Math.ceil(clean.length / charsPerLine); + return Math.max(1, lines, charLines); +} + +export function calculateOptionMinHeight(label: string): number { + if (!label) return 52; + + // If option has " - " separating title and description: + if (label.includes(" - ")) { + const parts = label.split(" - "); + const title = parts[0] || ""; + const desc = parts.slice(1).join(" - ") || ""; + const titleLines = estimateOptionLines(title, 26); + const descLines = estimateOptionLines(desc, 30); + // Vertical padding 24px + border 2px + title lines * 22px + gap 6px + desc lines * 19px + 4px breathing room + const total = 26 + (titleLines * 22) + 6 + (descLines * 19) + 4; + return Math.max(52, Math.round(total)); + } + + const lines = estimateOptionLines(label, 28); + // Base padding + border = 26px (py-3: 12px top + 12px bottom + 2px border) + // Each line is ~22px (15px font * 1.45 line-height) + 4px safety buffer + const total = 26 + (lines * 22) + 4; + return Math.max(52, Math.round(total)); +} + export type QuestionSheetProps = { question: QuestionField; disabled?: boolean; @@ -941,11 +987,14 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { !isSelected && Boolean(maxSelect && localSelectedList.length >= maxSelect); + const minHeightPx = calculateOptionMinHeight(option.label); + return ( { if (isOptionDisabled) return; if (isMulti) { @@ -954,13 +1003,16 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { handleSelectSingle(option.id); } }} + style={{ + minHeight: `${minHeightPx}px`, + }} className={[ - "flex min-h-[52px] w-full items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors", + "flex h-auto w-full shrink-0 items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors select-none box-border", isOptionDisabled - ? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3] pointer-events-none" + ? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3]" : isSelected - ? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818] cursor-pointer" - : "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818] cursor-pointer", + ? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818] cursor-pointer active:scale-[0.995]" + : "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818] cursor-pointer active:scale-[0.995]", ].join(" ")} > {/* Indicator Icon */} @@ -1008,7 +1060,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { )} - + {option.label.includes(" - ") ? ( (() => { const parts = option.label.split(" - "); @@ -1018,7 +1070,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { {option.label} )} - + ); })