Browse Source

fix(question-sheet): dynamic character-based card height calculation to prevent text overflow

staging
mortezaei 2 weeks ago
parent
commit
0e14a66f95
  1. 86
      src/components/Componentes/question-sheet.test.tsx
  2. 77
      src/components/Componentes/question-sheet.tsx

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

@ -9,7 +9,11 @@ import {
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionAnswersProvider } from "./question-answer-storage"; import { QuestionAnswersProvider } from "./question-answer-storage";
import { QuestionSheet } from "./question-sheet";
import {
QuestionSheet,
calculateOptionMinHeight,
estimateOptionLines,
} from "./question-sheet";
vi.mock("@/translations/provider", () => ({ vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })), useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })),
@ -1099,6 +1103,86 @@ describe("QuestionSheet component", () => {
expect(screen.getByRole("button", { name: "Albania" })).toBeDefined(); expect(screen.getByRole("button", { name: "Albania" })).toBeDefined();
expect(screen.queryByRole("button", { name: "Afghanistan" })).toBeNull(); 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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[longQuestion]}>
<QuestionSheet question={longQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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");
});
});
}); });

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

@ -38,6 +38,52 @@ const SHEET_MIN_SIZE = 0.6;
const SHEET_MAX_SIZE = 1; const SHEET_MAX_SIZE = 1;
const GESTURE_ENGAGE_PX = 10; 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 = { export type QuestionSheetProps = {
question: QuestionField; question: QuestionField;
disabled?: boolean; disabled?: boolean;
@ -941,11 +987,14 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
!isSelected && !isSelected &&
Boolean(maxSelect && localSelectedList.length >= maxSelect); Boolean(maxSelect && localSelectedList.length >= maxSelect);
const minHeightPx = calculateOptionMinHeight(option.label);
return ( return (
<button <button
key={option.id} key={option.id}
type="button" type="button"
disabled={isOptionDisabled} disabled={isOptionDisabled}
aria-pressed={isMulti ? isSelected : undefined}
onClick={() => { onClick={() => {
if (isOptionDisabled) return; if (isOptionDisabled) return;
if (isMulti) { if (isMulti) {
@ -954,13 +1003,16 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
handleSelectSingle(option.id); handleSelectSingle(option.id);
} }
}} }}
style={{
minHeight: `${minHeightPx}px`,
}}
className={[ 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 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 : 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(" ")} ].join(" ")}
> >
{/* Indicator Icon */} {/* Indicator Icon */}
@ -1008,7 +1060,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
</div> </div>
)} )}
<span className="text-[15px] leading-[1.45] flex-1 text-start break-words">
<div className="flex-1 min-w-0 text-start">
{option.label.includes(" - ") ? ( {option.label.includes(" - ") ? (
(() => { (() => {
const parts = option.label.split(" - "); const parts = option.label.split(" - ");
@ -1018,7 +1070,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<span className="flex flex-col gap-1 text-start"> <span className="flex flex-col gap-1 text-start">
<span <span
className={[ className={[
"font-bold",
"font-bold text-[15px] leading-[1.45] break-words [overflow-wrap:anywhere]",
isOptionDisabled isOptionDisabled
? "text-[#98A2B3]" ? "text-[#98A2B3]"
: "text-[#181818]", : "text-[#181818]",
@ -1040,18 +1092,19 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
})() })()
) : ( ) : (
<span <span
className={
className={[
"text-[15px] leading-[1.45] block break-words [overflow-wrap:anywhere]",
isOptionDisabled isOptionDisabled
? "font-normal text-[#98A2B3] block"
? "font-normal text-[#98A2B3]"
: isSelected : isSelected
? "font-bold text-[#181818] block"
: "font-semibold text-[#344054] block"
}
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]",
].join(" ")}
> >
{option.label} {option.label}
</span> </span>
)} )}
</span>
</div>
</button> </button>
); );
}) })

Loading…
Cancel
Save