Browse Source
feat: implement sheet scroll locking and keyboard-aware input focus management for mobile responsiveness
Dev
feat: implement sheet scroll locking and keyboard-aware input focus management for mobile responsiveness
Dev
49 changed files with 2798 additions and 840 deletions
-
36.zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md
-
42src/app/globals.css
-
92src/app/questions-list/[slug]/question-detail-client.test.tsx
-
66src/app/questions-list/[slug]/question-detail-client.tsx
-
223src/app/questions-list/page.tsx
-
53src/app/questions-list/section-prefetch.test.ts
-
24src/app/questions-list/section-prefetch.ts
-
23src/components/Componentes/question-answer-storage.tsx
-
497src/components/Componentes/question-birthplace.tsx
-
348src/components/Componentes/question-date-sheet.tsx
-
60src/components/Componentes/question-date.test.tsx
-
293src/components/Componentes/question-date.tsx
-
390src/components/Componentes/question-phone.tsx
-
4src/components/Componentes/question-renderer.tsx
-
182src/components/Componentes/question-sheet.test.tsx
-
416src/components/Componentes/question-sheet.tsx
-
345src/components/Componentes/question-snap-list.tsx
-
4src/components/Componentes/schema-question-flow.integration.test.tsx
-
166src/components/Componentes/ui-config.test.tsx
-
125src/components/Componentes/use-sheet-scroll-lock.ts
-
2src/hooks/marriage/types.ts
-
4src/hooks/marriage/use-form-schema.ts
-
1src/hooks/marriage/use-section-data.test.ts
-
29src/hooks/marriage/use-section-data.ts
-
1src/lib/schema-adapter-overview.test.ts
-
8src/translations/locales/ar.json
-
8src/translations/locales/az.json
-
8src/translations/locales/bn.json
-
8src/translations/locales/da.json
-
8src/translations/locales/de.json
-
8src/translations/locales/en.json
-
8src/translations/locales/es.json
-
8src/translations/locales/fa.json
-
8src/translations/locales/fr.json
-
8src/translations/locales/gu.json
-
8src/translations/locales/ha.json
-
10src/translations/locales/he.json
-
8src/translations/locales/hi.json
-
10src/translations/locales/id.json
-
10src/translations/locales/ks.json
-
10src/translations/locales/pt.json
-
8src/translations/locales/ru.json
-
10src/translations/locales/sw.json
-
10src/translations/locales/tg.json
-
10src/translations/locales/tr.json
-
10src/translations/locales/ul.json
-
10src/translations/locales/ur.json
-
10src/translations/locales/uz.json
-
8src/translations/locales/zh.json
@ -0,0 +1,36 @@ |
|||
## هدف |
|||
اسلاید بین سوالات مثل ریلز اینستاگرام / شورتز یوتیوب: محتوا 1:1 با انگشت حرکت کند، رها کردن بر اساس سرعت (flick) یا مسافت یا commit به بعدی/قبلی یا برگشت، rubber-band در دو انتها، و انیمیشن قابل مقطوعسازی (grab mid-animation). |
|||
|
|||
## تغییرات — فقط `src/components/Componentes/question-snap-list.tsx` |
|||
|
|||
### 1. لایهی درگ با دنبال کردن انگشت (بدون re-render در هر فریم) |
|||
- در `touchstart`: ثبت نقطه/زمان شروع + ارتفاع کانتینر. اگر لمس روی کنترلهای تعاملی شروع شود (`input, textarea, select, [contenteditable], [data-snap-drag-ignore]`) درگ فعال نمیشود تا با تایپ/اسلایدر تداخل نکند. |
|||
- در `touchmove` بعد از عبور از آستانه ~۱۰px: اعمال مستقیم `transform` روی سه پنل (فعال، بعدی، قبلی) از طریق ref (بدون state ریاکت) → 60fps: |
|||
- فعال: `translateY(-offset)` — بالا میرود با انگشت |
|||
- بعدی: `translateY(height - offset)` — از پایین بهصورت پنل کامل وارد میشود |
|||
- قبلی: `translateY(-height - offset)` — از بالا وارد میشود |
|||
- **Rubber-band**: در سوال اول/آخر، آفست با ضریب مقاومت 0.4 فشرده میشود. |
|||
- محاسبهی velocity با میانگین متحرک نمایی روی نمونههای حرکت. |
|||
|
|||
### 2. تصمیم رها کردن (touchend) |
|||
- commit به بعدی/قبلی اگر: مسافت > 30% ارتفاع **یا** |velocity| > 0.55 px/ms (flick). |
|||
- وگرنه spring-back با انیمیشن `cubic-bezier(0.22,1,0.36,1)` ~340ms. |
|||
- هنگام commit: همان callback های فعلی (`onQuestionExit`, `onQuestionTransition`) + `setActiveIndex` فراخوانی میشود؛ انیمیشن inline ادامه پیدا میکند و بعد از پایان، style های inline پاک میشوند تا state کلاسی بدون پرش تحویل داده شود. |
|||
|
|||
### 3. قابل مقطوعسازی |
|||
- اگر وسط انیمیشن snap دوباره انگشت بگذارد: موقعیت فعلی با `getComputedStyle + DOMMatrix` خوانده و فریز میشود و درگ از همان نقطه ادامه مییابد (مثل ریلز). |
|||
|
|||
### 4. حذف پیشنمایشهای ۱۱۰px (peek) |
|||
- برای اسلاید پنل کامل ریلز-مانند، پنلهای قبل/بعد در حالت سکون کاملاً خارج از دید (`translate-y-full` / `-translate-y-full`) قرار میگیرند و wrapper های `max-h-[110px] overflow-hidden` حذف میشوند. خودِ درگ جای affinity بصری را میگیرد. |
|||
- wheel دسکتاپ سرجایش میماند و حالا بهصورت اسلاید کامل انیمیت میشود (transition کلاس موجود). |
|||
|
|||
### 5. حفظ تمام رفتارهای فعلی |
|||
حفاظ `dropdown-open` (شیت باز = بدون درگ)، فوکوس خودکار input سوال فعال، init روی اولین سوال بیپاسخ، `alignTop`، اسپیسر 20%، `inert`/`aria`، `touchcancel` → برگشت، چند-لمسی → نادیده. |
|||
|
|||
## خارج از scope |
|||
- تغییر رفتار wheel/کیبورد دسکتاپ، مرورسازی horizontal، تغییر auto-focus. |
|||
- شیت کد کشور phone (همان 68svh) — طبق خواسته فقط QuestionSheet تغییر کرد. |
|||
|
|||
## راستیآزمایی |
|||
- `npx vitest run` + `npx tsc --noEmit` (یا biome). |
|||
- تست دستی در مرورگر با viewport موبایل: درگ با ماوس (شبیهسازی touch)، wheel، اسکرینشات قبل/بعد رها کردن، بررسی مرز rubber-band در اولین/آخرین سوال، باز کردن شیت nationality (تغییر 82svh قبلی) و فوکوس input متنی (حرکت به بالا با کیبورد). |
|||
@ -0,0 +1,53 @@ |
|||
import { describe, expect, it, vi } from "vitest"; |
|||
import type { QuestionListItem } from "@/lib/schema-adapter"; |
|||
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; |
|||
|
|||
function section(slug: string): QuestionListItem { |
|||
return { slug } as QuestionListItem; |
|||
} |
|||
|
|||
describe("prefetchSectionsWithBoundedConcurrency", () => { |
|||
it("starts promptly and runs at most two section requests at once", async () => { |
|||
const sections = [section("one"), section("two"), section("three")]; |
|||
const releases: Array<() => void> = []; |
|||
let activeRequests = 0; |
|||
let maximumActiveRequests = 0; |
|||
const prefetch = vi.fn(async () => { |
|||
activeRequests += 1; |
|||
maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests); |
|||
await new Promise<void>((resolve) => releases.push(resolve)); |
|||
activeRequests -= 1; |
|||
}); |
|||
|
|||
const queue = prefetchSectionsWithBoundedConcurrency( |
|||
sections, |
|||
prefetch, |
|||
() => false, |
|||
); |
|||
|
|||
expect(prefetch).toHaveBeenCalledTimes(2); |
|||
expect(maximumActiveRequests).toBe(2); |
|||
|
|||
releases.shift()?.(); |
|||
await vi.waitFor(() => expect(prefetch).toHaveBeenCalledTimes(3)); |
|||
expect(maximumActiveRequests).toBe(2); |
|||
|
|||
releases.splice(0).forEach((release) => release()); |
|||
await queue; |
|||
}); |
|||
|
|||
it("continues after a background request fails", async () => { |
|||
const prefetch = vi |
|||
.fn<(item: QuestionListItem) => Promise<void>>() |
|||
.mockRejectedValueOnce(new Error("offline")) |
|||
.mockResolvedValue(undefined); |
|||
|
|||
await prefetchSectionsWithBoundedConcurrency( |
|||
[section("one"), section("two"), section("three")], |
|||
prefetch, |
|||
() => false, |
|||
); |
|||
|
|||
expect(prefetch).toHaveBeenCalledTimes(3); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,24 @@ |
|||
import type { QuestionListItem } from "@/lib/schema-adapter"; |
|||
|
|||
export async function prefetchSectionsWithBoundedConcurrency( |
|||
sections: readonly QuestionListItem[], |
|||
prefetch: (section: QuestionListItem) => Promise<unknown>, |
|||
isCancelled: () => boolean, |
|||
) { |
|||
let nextSectionIndex = 0; |
|||
|
|||
const runWorker = async () => { |
|||
while (!isCancelled()) { |
|||
const section = sections[nextSectionIndex++]; |
|||
if (!section) return; |
|||
|
|||
try { |
|||
await prefetch(section); |
|||
} catch { |
|||
// Interaction or navigation can retry a failed background prefetch.
|
|||
} |
|||
} |
|||
}; |
|||
|
|||
await Promise.all([runWorker(), runWorker()]); |
|||
} |
|||
@ -0,0 +1,348 @@ |
|||
"use client"; |
|||
|
|||
import { |
|||
type ReactNode, |
|||
useCallback, |
|||
useEffect, |
|||
useMemo, |
|||
useRef, |
|||
useState, |
|||
} from "react"; |
|||
import { createPortal } from "react-dom"; |
|||
import type { QuestionField } from "@/lib/schema-adapter"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
import { useSheetScrollLock } from "./use-sheet-scroll-lock"; |
|||
|
|||
const EXIT_ANIMATION_MS = 300; |
|||
const WHEEL_ITEM_HEIGHT = 48; |
|||
const WHEEL_EDGE_PADDING = 108; |
|||
const SCROLL_SETTLE_MS = 90; |
|||
|
|||
const MONTH_VALUES = [ |
|||
"01", |
|||
"02", |
|||
"03", |
|||
"04", |
|||
"05", |
|||
"06", |
|||
"07", |
|||
"08", |
|||
"09", |
|||
"10", |
|||
"11", |
|||
"12", |
|||
]; |
|||
|
|||
const MIN_AGE = 18; |
|||
const currentYear = new Date().getFullYear(); |
|||
const maxBirthYear = currentYear - MIN_AGE; |
|||
const YEARS = Array.from({ length: 80 }, (_, i) => |
|||
(maxBirthYear - i).toString(), |
|||
); |
|||
|
|||
function daysInMonth(year: string, month: string): number { |
|||
const y = Number.parseInt(year, 10); |
|||
const m = Number.parseInt(month, 10); |
|||
if (!y || !m) return 31; |
|||
return new Date(y, m, 0).getDate(); |
|||
} |
|||
|
|||
type DatePart = "day" | "month" | "year"; |
|||
|
|||
type QuestionDateSheetProps = { |
|||
question: QuestionField; |
|||
value: string; |
|||
onApply: (formattedDate: string) => void; |
|||
onClose: () => void; |
|||
}; |
|||
|
|||
function WheelColumn({ |
|||
ariaLabel, |
|||
items, |
|||
selectedIndex, |
|||
onSelect, |
|||
}: { |
|||
ariaLabel: string; |
|||
items: Array<{ value: string; label: ReactNode }>; |
|||
selectedIndex: number; |
|||
onSelect: (index: number) => void; |
|||
}) { |
|||
const listRef = useRef<HTMLDivElement>(null); |
|||
const settleTimeoutRef = useRef<number | null>(null); |
|||
const initialScrollDoneRef = useRef(false); |
|||
|
|||
useEffect(() => { |
|||
const frame = window.requestAnimationFrame(() => { |
|||
const list = listRef.current; |
|||
if (!list || initialScrollDoneRef.current) return; |
|||
initialScrollDoneRef.current = true; |
|||
list.scrollTop = selectedIndex * WHEEL_ITEM_HEIGHT; |
|||
}); |
|||
return () => window.cancelAnimationFrame(frame); |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
return () => { |
|||
if (settleTimeoutRef.current !== null) { |
|||
window.clearTimeout(settleTimeoutRef.current); |
|||
} |
|||
}; |
|||
}, []); |
|||
|
|||
const handleScroll = useCallback(() => { |
|||
const list = listRef.current; |
|||
if (!list) return; |
|||
if (settleTimeoutRef.current !== null) { |
|||
window.clearTimeout(settleTimeoutRef.current); |
|||
} |
|||
settleTimeoutRef.current = window.setTimeout(() => { |
|||
settleTimeoutRef.current = null; |
|||
const index = Math.max( |
|||
0, |
|||
Math.min( |
|||
items.length - 1, |
|||
Math.round(list.scrollTop / WHEEL_ITEM_HEIGHT), |
|||
), |
|||
); |
|||
if (index !== selectedIndex) { |
|||
onSelect(index); |
|||
} |
|||
}, SCROLL_SETTLE_MS); |
|||
}, [items.length, onSelect, selectedIndex]); |
|||
|
|||
return ( |
|||
<div |
|||
ref={listRef} |
|||
aria-label={ariaLabel} |
|||
onScroll={handleScroll} |
|||
onTouchStart={(event) => event.stopPropagation()} |
|||
onTouchMove={(event) => event.stopPropagation()} |
|||
onTouchEnd={(event) => event.stopPropagation()} |
|||
className="h-[264px] flex-1 snap-y snap-mandatory overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" |
|||
> |
|||
<div style={{ height: WHEEL_EDGE_PADDING }} aria-hidden="true" /> |
|||
{items.map((item, index) => { |
|||
const isSelected = index === selectedIndex; |
|||
return ( |
|||
<div |
|||
key={item.value} |
|||
aria-selected={isSelected} |
|||
className="flex h-12 snap-center items-center justify-center" |
|||
> |
|||
<span |
|||
className={[ |
|||
"text-[15px] transition-colors", |
|||
isSelected |
|||
? "font-bold text-[#181818]" |
|||
: "font-medium text-[#667085]", |
|||
].join(" ")} |
|||
> |
|||
{item.label} |
|||
</span> |
|||
</div> |
|||
); |
|||
})} |
|||
<div style={{ height: WHEEL_EDGE_PADDING }} aria-hidden="true" /> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export function QuestionDateSheet({ |
|||
question, |
|||
value, |
|||
onApply, |
|||
onClose, |
|||
}: QuestionDateSheetProps) { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const [isClosing, setIsClosing] = useState(false); |
|||
|
|||
const parts = value ? value.split("-") : []; |
|||
const [selectedYear, setSelectedYear] = useState(parts[0] || YEARS[0]); |
|||
const [selectedMonth, setSelectedMonth] = useState( |
|||
parts[1] || MONTH_VALUES[0], |
|||
); |
|||
const [selectedDay, setSelectedDay] = useState(parts[2] || "01"); |
|||
const dirtyRef = useRef(false); |
|||
|
|||
useSheetScrollLock(true); |
|||
|
|||
const closeSheet = useCallback(() => { |
|||
setIsClosing(true); |
|||
window.setTimeout(onClose, EXIT_ANIMATION_MS); |
|||
}, [onClose]); |
|||
|
|||
useEffect(() => { |
|||
const handleKeyDown = (e: KeyboardEvent) => { |
|||
if (e.key === "Escape") { |
|||
closeSheet(); |
|||
} |
|||
}; |
|||
window.addEventListener("keydown", handleKeyDown); |
|||
return () => window.removeEventListener("keydown", handleKeyDown); |
|||
}, [closeSheet]); |
|||
|
|||
const months = useMemo(() => { |
|||
return MONTH_VALUES.map((val, idx) => { |
|||
const date = new Date(2000, idx, 15); |
|||
const label = new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, { |
|||
month: "long", |
|||
}).format(date); |
|||
return { value: val, label }; |
|||
}); |
|||
}, [locale]); |
|||
|
|||
const days = useMemo(() => { |
|||
return Array.from({ length: 31 }, (_, i) => { |
|||
const num = i + 1; |
|||
const val = num < 10 ? `0${num}` : `${num}`; |
|||
return { |
|||
value: val, |
|||
label: new Intl.NumberFormat(locale).format(num), |
|||
}; |
|||
}); |
|||
}, [locale]); |
|||
|
|||
const years = useMemo(() => { |
|||
return YEARS.map((y) => ({ |
|||
value: y, |
|||
label: new Intl.NumberFormat(locale, { useGrouping: false }).format( |
|||
Number.parseInt(y, 10), |
|||
), |
|||
})); |
|||
}, [locale]); |
|||
|
|||
const dayIndex = Math.max( |
|||
0, |
|||
days.findIndex((d) => d.value === selectedDay), |
|||
); |
|||
const monthIndex = Math.max( |
|||
0, |
|||
months.findIndex((m) => m.value === selectedMonth), |
|||
); |
|||
const yearIndex = Math.max( |
|||
0, |
|||
years.findIndex((y) => y.value === selectedYear), |
|||
); |
|||
|
|||
// Keep the day valid for the selected month/year (Feb, leap years, ...).
|
|||
useEffect(() => { |
|||
const maxDay = daysInMonth(selectedYear, selectedMonth); |
|||
const dayNum = Number.parseInt(selectedDay, 10); |
|||
if (dayNum > maxDay) { |
|||
setSelectedDay(String(maxDay).padStart(2, "0")); |
|||
} |
|||
}, [selectedYear, selectedMonth, selectedDay]); |
|||
|
|||
// Apply the picked date immediately while spinning the wheels, so the
|
|||
// field above the sheet always reflects the current selection.
|
|||
useEffect(() => { |
|||
if (!dirtyRef.current) return; |
|||
const maxDay = daysInMonth(selectedYear, selectedMonth); |
|||
const dayNum = Math.min(Number.parseInt(selectedDay, 10), maxDay); |
|||
const formattedDay = String(dayNum).padStart(2, "0"); |
|||
onApply( |
|||
`${selectedYear}-${selectedMonth.padStart(2, "0")}-${formattedDay}`, |
|||
); |
|||
}, [selectedYear, selectedMonth, selectedDay, onApply]); |
|||
|
|||
const handleSelect = useCallback((part: DatePart, index: number) => { |
|||
dirtyRef.current = true; |
|||
if (part === "day") { |
|||
setSelectedDay(String(index + 1).padStart(2, "0")); |
|||
} else if (part === "month") { |
|||
setSelectedMonth(MONTH_VALUES[index]); |
|||
} else { |
|||
setSelectedYear(YEARS[index]); |
|||
} |
|||
}, []); |
|||
|
|||
const dayLabel = t.Day || "Day"; |
|||
const monthLabel = t.Month || "Month"; |
|||
const yearLabel = t.Year || "Year"; |
|||
|
|||
return createPortal( |
|||
<div |
|||
className="fixed inset-0 z-50 flex items-end justify-center bg-transparent" |
|||
role="dialog" |
|||
aria-modal="true" |
|||
aria-label={question.title} |
|||
onKeyDown={(event) => { |
|||
if (event.key === "Escape") closeSheet(); |
|||
}} |
|||
onClick={(event) => { |
|||
if (event.target === event.currentTarget) closeSheet(); |
|||
}} |
|||
> |
|||
<section |
|||
className={[ |
|||
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[calc(16px+env(safe-area-inset-bottom))] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom", |
|||
isClosing ? "translate-y-full" : "translate-y-0", |
|||
].join(" ")} |
|||
> |
|||
<div className="flex items-center justify-between border-b border-[#F2F4F7] px-5 pb-3 pt-2"> |
|||
<h3 className="truncate pr-2 text-[17px] font-bold text-[#181818]"> |
|||
{question.title} |
|||
</h3> |
|||
<button |
|||
type="button" |
|||
onClick={closeSheet} |
|||
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#667085] transition-colors hover:bg-[#F2F4F7] hover:text-[#181818]" |
|||
aria-label="Close" |
|||
> |
|||
<svg |
|||
aria-hidden="true" |
|||
width="18" |
|||
height="18" |
|||
viewBox="0 0 24 24" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
strokeWidth="2.2" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
> |
|||
<line x1="18" y1="6" x2="6" y2="18" /> |
|||
<line x1="6" y1="6" x2="18" y2="18" /> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
|
|||
<div className="px-5 pt-4"> |
|||
<div className="grid grid-cols-3 gap-2 pb-2 text-center text-[11px] font-semibold text-[#667085]"> |
|||
<span>{dayLabel}</span> |
|||
<span>{monthLabel}</span> |
|||
<span>{yearLabel}</span> |
|||
</div> |
|||
<div className="relative"> |
|||
<div |
|||
aria-hidden="true" |
|||
className="pointer-events-none absolute inset-x-0 top-1/2 h-12 -translate-y-1/2 rounded-[8px] bg-[#F2F4F7]" |
|||
/> |
|||
<div className="relative flex gap-2"> |
|||
<WheelColumn |
|||
ariaLabel={dayLabel} |
|||
items={days} |
|||
selectedIndex={dayIndex} |
|||
onSelect={(index) => handleSelect("day", index)} |
|||
/> |
|||
<WheelColumn |
|||
ariaLabel={monthLabel} |
|||
items={months} |
|||
selectedIndex={monthIndex} |
|||
onSelect={(index) => handleSelect("month", index)} |
|||
/> |
|||
<WheelColumn |
|||
ariaLabel={yearLabel} |
|||
items={years} |
|||
selectedIndex={yearIndex} |
|||
onSelect={(index) => handleSelect("year", index)} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div>, |
|||
document.body, |
|||
); |
|||
} |
|||
|
|||
export default QuestionDateSheet; |
|||
@ -0,0 +1,60 @@ |
|||
import { fireEvent, render, screen } from "@testing-library/react"; |
|||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import type { QuestionField } from "@/lib/schema-adapter"; |
|||
import { QuestionDate } from "./question-date"; |
|||
|
|||
let answerValue: string | null = null; |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ |
|||
locale: "en", |
|||
dictionary: { Age: "Age", Day: "Day", Month: "Month", Year: "Year" }, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("./question-answer-storage", () => ({ |
|||
useQuestionAnswers: () => ({ |
|||
getAnswerValue: () => answerValue, |
|||
setAnswerValue: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
const question = { |
|||
id: "personal_identity.date_of_birth", |
|||
title: "Date of Birth", |
|||
type: "date", |
|||
order: 1, |
|||
required: true, |
|||
baseRequired: true, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "YYYY-MM-DD", range: [0, 0], options: [] }, |
|||
options: [], |
|||
ui_config: { isDob: true }, |
|||
} as QuestionField; |
|||
|
|||
describe("QuestionDate", () => { |
|||
beforeEach(() => { |
|||
answerValue = null; |
|||
}); |
|||
|
|||
it("hides the derived age until a birth date exists", () => { |
|||
const { rerender } = render(<QuestionDate question={question} />); |
|||
expect(screen.queryByText("Age")).toBeNull(); |
|||
|
|||
answerValue = "1979-05-15"; |
|||
rerender(<QuestionDate question={question} />); |
|||
|
|||
expect(screen.getByText("Age")).toBeDefined(); |
|||
expect(screen.getByDisplayValue(/^\d+$/)).toBeDefined(); |
|||
}); |
|||
|
|||
it("renders calendar years without thousands separators", () => { |
|||
render(<QuestionDate question={question} />); |
|||
fireEvent.click(screen.getByRole("button", { name: /select date/i })); |
|||
|
|||
const yearWheel = screen.getByLabelText("Year"); |
|||
expect(yearWheel.textContent).not.toMatch(/[,٬]/); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,182 @@ |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { |
|||
cleanup, |
|||
fireEvent, |
|||
render, |
|||
screen, |
|||
waitFor, |
|||
} from "@testing-library/react"; |
|||
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"; |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-section-data", () => ({ |
|||
applyProfilePatchResultToCache: vi.fn(), |
|||
useMarriageSectionDataQuery: vi.fn(() => ({ |
|||
data: undefined, |
|||
isLoading: false, |
|||
})), |
|||
useUpdateMarriageSectionDataMutation: vi.fn(() => ({ |
|||
mutateAsync: vi.fn().mockResolvedValue({}), |
|||
isPending: false, |
|||
})), |
|||
})); |
|||
|
|||
describe("QuestionSheet component", () => { |
|||
afterEach(() => { |
|||
cleanup(); |
|||
document.body.classList.remove("dropdown-open"); |
|||
document.body.classList.remove("question-input-open"); |
|||
}); |
|||
|
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { queries: { retry: false } }, |
|||
}); |
|||
|
|||
it("renders trigger and opens bottom sheet on click", async () => { |
|||
const qDropdown = { |
|||
id: "q_nat", |
|||
title: "تابعیت و شهروندی", |
|||
type: "dropdown", |
|||
order: 1, |
|||
required: true, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "انتخاب کشور" }, |
|||
options: [ |
|||
{ id: "iran", value: "Iran", label: "ایران", order: 1 }, |
|||
{ id: "germany", value: "Germany", label: "آلمان", order: 2 }, |
|||
{ id: "canada", value: "Canada", label: "کانادا", order: 3 }, |
|||
], |
|||
ui_config: {}, |
|||
} as QuestionField; |
|||
|
|||
render( |
|||
<div className="app-shell"> |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qDropdown]}> |
|||
<QuestionSheet question={qDropdown} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
</div>, |
|||
); |
|||
|
|||
// Initial placeholder displayed in trigger
|
|||
expect(screen.getByText("انتخاب کشور")).toBeDefined(); |
|||
|
|||
// Click trigger to open bottom sheet
|
|||
const trigger = screen.getByRole("button", { name: /انتخاب کشور/i }); |
|||
fireEvent.click(trigger); |
|||
|
|||
// Bottom sheet dialog should be visible
|
|||
expect(screen.getByRole("dialog")).toBeDefined(); |
|||
expect(document.body.classList.contains("dropdown-open")).toBe(true); |
|||
expect(document.body.classList.contains("question-input-open")).toBe(true); |
|||
expect( |
|||
(document.querySelector(".app-shell") as HTMLElement).style.overflowY, |
|||
).toBe("hidden"); |
|||
expect(screen.getByRole("button", { name: "ایران" })).toBeDefined(); |
|||
expect(screen.getByRole("button", { name: "آلمان" })).toBeDefined(); |
|||
|
|||
// Select Iran
|
|||
const iranBtn = screen.getByRole("button", { name: "ایران" }); |
|||
fireEvent.click(iranBtn); |
|||
|
|||
// Trigger should now show Iran
|
|||
await waitFor(() => { |
|||
expect(screen.getByText("ایران")).toBeDefined(); |
|||
expect(document.body.classList.contains("dropdown-open")).toBe(false); |
|||
expect(document.body.classList.contains("question-input-open")).toBe( |
|||
false, |
|||
); |
|||
expect( |
|||
(document.querySelector(".app-shell") as HTMLElement).style.overflowY, |
|||
).toBe(""); |
|||
}); |
|||
}); |
|||
|
|||
it("supports multi-selection with confirm button", async () => { |
|||
const qMulti = { |
|||
id: "q_lang", |
|||
title: "سایر زبانها", |
|||
type: "dropdown", |
|||
order: 2, |
|||
required: false, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "انتخاب زبانها", range: [0, 5] }, |
|||
options: [ |
|||
{ id: "en", value: "English", label: "انگلیسی", order: 1 }, |
|||
{ id: "ar", value: "Arabic", label: "عربی", order: 2 }, |
|||
{ id: "fr", value: "French", label: "فرانسوی", order: 3 }, |
|||
], |
|||
ui_config: {}, |
|||
} as QuestionField; |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qMulti]}> |
|||
<QuestionSheet question={qMulti} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider>, |
|||
); |
|||
|
|||
// Click trigger
|
|||
const trigger = screen.getByRole("button", { name: /انتخاب زبانها/i }); |
|||
fireEvent.click(trigger); |
|||
|
|||
// Select English and French
|
|||
const enBtn = screen.getByRole("button", { name: "انگلیسی" }); |
|||
const frBtn = screen.getByRole("button", { name: "فرانسوی" }); |
|||
fireEvent.click(enBtn); |
|||
fireEvent.click(frBtn); |
|||
|
|||
// Confirm button should show count
|
|||
const confirmBtn = screen.getByRole("button", { name: /تایید \(2\)/i }); |
|||
expect(confirmBtn).toBeDefined(); |
|||
fireEvent.click(confirmBtn); |
|||
|
|||
// Trigger displays selected items
|
|||
await waitFor(() => { |
|||
expect(screen.getByText("انگلیسی, فرانسوی")).toBeDefined(); |
|||
}); |
|||
}); |
|||
|
|||
it("closes the inline selector with Escape", async () => { |
|||
const question = { |
|||
id: "q_drag", |
|||
title: "کشور محل اقامت", |
|||
type: "dropdown", |
|||
required: true, |
|||
extras: { placeHolder: "انتخاب کشور" }, |
|||
options: [{ id: "iran", value: "Iran", label: "ایران", order: 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 })); |
|||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.queryByRole("dialog")).toBeNull(); |
|||
expect(document.body.classList.contains("dropdown-open")).toBe(false); |
|||
expect(document.body.classList.contains("question-input-open")).toBe( |
|||
false, |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,416 @@ |
|||
"use client"; |
|||
|
|||
import { useCallback, useEffect, useRef, useState } from "react"; |
|||
import { createPortal } from "react-dom"; |
|||
import type { QuestionField } from "@/lib/schema-adapter"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
import { Button } from "./button"; |
|||
import { ExplanationUiFont } from "./explanation-ui-font"; |
|||
import { useQuestionAnswers } from "./question-answer-storage"; |
|||
import QuestionTitle from "./question-title"; |
|||
import { useSheetScrollLock } from "./use-sheet-scroll-lock"; |
|||
|
|||
const EXIT_ANIMATION_MS = 300; |
|||
|
|||
export type QuestionSheetProps = { |
|||
question: QuestionField; |
|||
disabled?: boolean; |
|||
}; |
|||
|
|||
export function QuestionSheet({ question, disabled }: QuestionSheetProps) { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); |
|||
const rawValue = getAnswerValue(question); |
|||
|
|||
const isMulti = |
|||
Array.isArray(rawValue) || |
|||
question.type === "checkbox" || |
|||
(question.extras?.range && question.extras.range[1] > 1); |
|||
|
|||
const selectedList = Array.isArray(rawValue) |
|||
? rawValue |
|||
: typeof rawValue === "string" && rawValue |
|||
? [rawValue] |
|||
: []; |
|||
const singleValue = typeof rawValue === "string" ? rawValue : ""; |
|||
|
|||
const [isOpen, setIsOpen] = useState(false); |
|||
const [isClosing, setIsClosing] = useState(false); |
|||
const [searchQuery, setSearchQuery] = useState(""); |
|||
const searchInputRef = useRef<HTMLInputElement>(null); |
|||
const listRef = useRef<HTMLDivElement>(null); |
|||
const closeSheet = useCallback(() => { |
|||
setIsClosing(true); |
|||
window.setTimeout(() => { |
|||
setIsOpen(false); |
|||
setIsClosing(false); |
|||
setSearchQuery(""); |
|||
}, EXIT_ANIMATION_MS); |
|||
}, []); |
|||
|
|||
const openSheet = useCallback(() => { |
|||
if (disabled) return; |
|||
setIsOpen(true); |
|||
setIsClosing(false); |
|||
}, [disabled]); |
|||
|
|||
useSheetScrollLock(isOpen); |
|||
|
|||
// Handle escape key
|
|||
useEffect(() => { |
|||
if (!isOpen) return; |
|||
|
|||
const handleKeyDown = (e: KeyboardEvent) => { |
|||
if (e.key === "Escape") { |
|||
closeSheet(); |
|||
} |
|||
}; |
|||
|
|||
window.addEventListener("keydown", handleKeyDown); |
|||
return () => window.removeEventListener("keydown", handleKeyDown); |
|||
}, [isOpen, closeSheet]); |
|||
|
|||
const options = question.options || []; |
|||
|
|||
const showSearch = (() => { |
|||
if (question.extras?.noSearch) return false; |
|||
return options.length > 5; |
|||
})(); |
|||
|
|||
const filteredOptions = options.filter((option) => |
|||
option.label.toLowerCase().includes(searchQuery.toLowerCase()), |
|||
); |
|||
|
|||
const getCleanLabel = (optId: string) => { |
|||
const opt = options.find((o) => o.id === optId); |
|||
if (!opt) return optId; |
|||
return opt.label.split(" - ")[0]; |
|||
}; |
|||
|
|||
const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; |
|||
const defaultPlaceholder = isRtl ? "انتخاب کنید" : "Select"; |
|||
|
|||
const displayLabel = isMulti |
|||
? selectedList.length > 0 |
|||
? selectedList.map(getCleanLabel).join(", ") |
|||
: question.extras?.placeHolder || defaultPlaceholder |
|||
: singleValue |
|||
? getCleanLabel(singleValue) |
|||
: question.extras?.placeHolder || defaultPlaceholder; |
|||
|
|||
const hasSelectedValue = isMulti |
|||
? selectedList.length > 0 |
|||
: Boolean(singleValue); |
|||
|
|||
const toggleMultiOption = (optionId: string) => { |
|||
let nextValue: string[]; |
|||
if (selectedList.includes(optionId)) { |
|||
nextValue = selectedList.filter((v) => v !== optionId); |
|||
} else { |
|||
nextValue = [...selectedList, optionId]; |
|||
} |
|||
setAnswerValue(question, nextValue.length > 0 ? nextValue : null); |
|||
}; |
|||
|
|||
const handleSelectSingle = (optionId: string) => { |
|||
setAnswerValue(question, optionId); |
|||
closeSheet(); |
|||
}; |
|||
|
|||
const searchPlaceholder = |
|||
locale === "fa" |
|||
? "جستجو..." |
|||
: locale === "ar" |
|||
? "بحث..." |
|||
: locale === "tr" |
|||
? "Ara..." |
|||
: "Search..."; |
|||
|
|||
const noResultsText = |
|||
locale === "fa" |
|||
? "موردی یافت نشد" |
|||
: locale === "ar" |
|||
? "لم يتم العثور على نتائج" |
|||
: "No options found"; |
|||
|
|||
const confirmText = t.Confirm || (isRtl ? "تایید" : "Confirm"); |
|||
|
|||
return ( |
|||
<div |
|||
className={[ |
|||
"relative flex w-full flex-col gap-2.5 transition-opacity duration-200", |
|||
disabled ? "pointer-events-none opacity-30" : "", |
|||
].join(" ")} |
|||
> |
|||
<QuestionTitle question={question} /> |
|||
|
|||
{/* Select Trigger Button (Input style) */} |
|||
<button |
|||
type="button" |
|||
disabled={disabled} |
|||
onClick={openSheet} |
|||
className={[ |
|||
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none", |
|||
isOpen |
|||
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]" |
|||
: "border-[#D0D5DD] hover:border-[#98A2B3]", |
|||
].join(" ")} |
|||
> |
|||
<span |
|||
className={[ |
|||
"text-[15px] font-medium truncate", |
|||
hasSelectedValue ? "text-[#181818]" : "text-[#667085]", |
|||
].join(" ")} |
|||
> |
|||
{displayLabel} |
|||
</span> |
|||
<svg |
|||
aria-hidden="true" |
|||
width="16" |
|||
height="10" |
|||
viewBox="0 0 16 10" |
|||
fill="none" |
|||
className={[ |
|||
"shrink-0 transition-transform duration-200", |
|||
isOpen ? "rotate-180" : "", |
|||
].join(" ")} |
|||
> |
|||
<path |
|||
d="M14.75 1.25L7.75 8.25L0.75 1.25" |
|||
stroke="#344054" |
|||
strokeWidth="1.75" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
/> |
|||
</svg> |
|||
</button> |
|||
|
|||
{/* The active field moves up while the options sheet enters from below. */} |
|||
{isOpen && |
|||
createPortal( |
|||
<div |
|||
className="fixed inset-0 z-50 flex items-end justify-center bg-transparent" |
|||
role="dialog" |
|||
aria-label={question.title} |
|||
onKeyDown={(e) => { |
|||
if (e.key === "Escape") closeSheet(); |
|||
}} |
|||
onClick={(event) => { |
|||
if (event.target === event.currentTarget) closeSheet(); |
|||
}} |
|||
> |
|||
<section |
|||
className={[ |
|||
"flex h-[82svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom", |
|||
isClosing ? "translate-y-full" : "translate-y-0", |
|||
].join(" ")} |
|||
> |
|||
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]"> |
|||
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2"> |
|||
{question.title} |
|||
</h3> |
|||
<button |
|||
type="button" |
|||
onClick={closeSheet} |
|||
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer" |
|||
aria-label="Close" |
|||
> |
|||
<svg |
|||
aria-hidden="true" |
|||
width="18" |
|||
height="18" |
|||
viewBox="0 0 24 24" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
strokeWidth="2.2" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
> |
|||
<line x1="18" y1="6" x2="6" y2="18" /> |
|||
<line x1="6" y1="6" x2="18" y2="18" /> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
|
|||
{/* Search Bar */} |
|||
{showSearch && ( |
|||
<div className="px-5 pt-3.5 pb-2"> |
|||
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]"> |
|||
<svg |
|||
aria-hidden="true" |
|||
width="18" |
|||
height="18" |
|||
viewBox="0 0 18 18" |
|||
fill="none" |
|||
className="shrink-0 text-[#667085]" |
|||
> |
|||
<path |
|||
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z" |
|||
stroke="#667085" |
|||
strokeWidth="1.75" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
/> |
|||
<path |
|||
d="M15.75 15.75L12.5 12.5" |
|||
stroke="#667085" |
|||
strokeWidth="1.75" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
/> |
|||
</svg> |
|||
<input |
|||
ref={searchInputRef} |
|||
type="text" |
|||
value={searchQuery} |
|||
onChange={(e) => setSearchQuery(e.target.value)} |
|||
placeholder={searchPlaceholder} |
|||
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" |
|||
/> |
|||
{searchQuery ? ( |
|||
<button |
|||
type="button" |
|||
onClick={() => setSearchQuery("")} |
|||
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer" |
|||
> |
|||
✕ |
|||
</button> |
|||
) : null} |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{/* Options List */} |
|||
<div |
|||
ref={listRef} |
|||
onTouchStart={(event) => event.stopPropagation()} |
|||
onTouchMove={(event) => event.stopPropagation()} |
|||
onTouchEnd={(event) => event.stopPropagation()} |
|||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3" |
|||
> |
|||
{filteredOptions.length > 0 ? ( |
|||
filteredOptions.map((option) => { |
|||
const isSelected = isMulti |
|||
? selectedList.includes(option.id) |
|||
: singleValue === option.id; |
|||
|
|||
return ( |
|||
<button |
|||
key={option.id} |
|||
type="button" |
|||
onClick={() => { |
|||
if (isMulti) { |
|||
toggleMultiOption(option.id); |
|||
} else { |
|||
handleSelectSingle(option.id); |
|||
} |
|||
}} |
|||
className={[ |
|||
"flex min-h-12 w-full items-start gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer", |
|||
isSelected |
|||
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]" |
|||
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]", |
|||
].join(" ")} |
|||
> |
|||
{/* Indicator Icon */} |
|||
{isMulti ? ( |
|||
<div |
|||
className={[ |
|||
"size-[22px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5 flex items-center justify-center", |
|||
isSelected |
|||
? "bg-[#F0445B] text-white shadow-xs" |
|||
: "border-[2px] border-[#98A2B3] bg-white", |
|||
].join(" ")} |
|||
> |
|||
{isSelected && ( |
|||
<svg |
|||
aria-hidden="true" |
|||
width="12" |
|||
height="10" |
|||
viewBox="0 0 12 10" |
|||
fill="none" |
|||
> |
|||
<path |
|||
d="M1.5 5L4.5 8L10.5 2" |
|||
stroke="currentColor" |
|||
strokeWidth="2" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
/> |
|||
</svg> |
|||
)} |
|||
</div> |
|||
) : ( |
|||
<div |
|||
className={[ |
|||
"size-[22px] shrink-0 rounded-full transition-all duration-150 mt-0.5 flex items-center justify-center", |
|||
isSelected |
|||
? "border-[6px] border-[#F0445B] bg-white" |
|||
: "border-[2px] border-[#98A2B3] bg-white", |
|||
].join(" ")} |
|||
/> |
|||
)} |
|||
|
|||
<span className="text-[15px] leading-snug flex-1"> |
|||
{option.label.includes(" - ") ? ( |
|||
(() => { |
|||
const parts = option.label.split(" - "); |
|||
const title = parts[0]; |
|||
const description = parts.slice(1).join(" - "); |
|||
return ( |
|||
<span className="flex flex-col gap-1 text-start"> |
|||
<span className="font-bold text-[#181818]"> |
|||
{title} |
|||
</span> |
|||
<ExplanationUiFont> |
|||
{description} |
|||
</ExplanationUiFont> |
|||
</span> |
|||
); |
|||
})() |
|||
) : ( |
|||
<span |
|||
className={ |
|||
isSelected |
|||
? "font-bold text-[#181818]" |
|||
: "font-semibold text-[#344054]" |
|||
} |
|||
> |
|||
{option.label} |
|||
</span> |
|||
)} |
|||
</span> |
|||
</button> |
|||
); |
|||
}) |
|||
) : ( |
|||
<div className="py-8 text-center text-[14px] text-[#667085]"> |
|||
{noResultsText} |
|||
</div> |
|||
)} |
|||
</div> |
|||
|
|||
{/* Bottom Actions for Multi-Select */} |
|||
{isMulti && ( |
|||
<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)]" |
|||
> |
|||
{selectedList.length > 0 |
|||
? `${confirmText} (${selectedList.length})` |
|||
: confirmText} |
|||
</Button> |
|||
</div> |
|||
)} |
|||
</section> |
|||
</div>, |
|||
document.body, |
|||
)} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default QuestionSheet; |
|||
@ -0,0 +1,125 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect } from "react"; |
|||
|
|||
let activeSheetCount = 0; |
|||
let activeKeyboardInputCount = 0; |
|||
let bodyHadDropdownClass = false; |
|||
let initialBodyOverflow = ""; |
|||
let initialHtmlOverflow = ""; |
|||
let initialAppShellOverflow = ""; |
|||
let initialAppShellTouchAction = ""; |
|||
let lockedAppShell: HTMLElement | null = null; |
|||
|
|||
export function useSheetScrollLock(isOpen: boolean) { |
|||
useEffect(() => { |
|||
if (!isOpen) return; |
|||
|
|||
if (activeSheetCount === 0) { |
|||
bodyHadDropdownClass = document.body.classList.contains("dropdown-open"); |
|||
initialBodyOverflow = document.body.style.overflow; |
|||
initialHtmlOverflow = document.documentElement.style.overflow; |
|||
lockedAppShell = document.querySelector<HTMLElement>(".app-shell"); |
|||
initialAppShellOverflow = lockedAppShell?.style.overflowY ?? ""; |
|||
initialAppShellTouchAction = lockedAppShell?.style.touchAction ?? ""; |
|||
} |
|||
activeSheetCount += 1; |
|||
|
|||
document.body.classList.add("dropdown-open"); |
|||
document.body.classList.add("question-input-open"); |
|||
document.body.style.overflow = "hidden"; |
|||
document.documentElement.style.overflow = "hidden"; |
|||
if (lockedAppShell) { |
|||
lockedAppShell.style.overflowY = "hidden"; |
|||
lockedAppShell.style.touchAction = "none"; |
|||
} |
|||
|
|||
return () => { |
|||
activeSheetCount = Math.max(0, activeSheetCount - 1); |
|||
if (activeSheetCount === 0) { |
|||
document.body.style.overflow = initialBodyOverflow; |
|||
document.documentElement.style.overflow = initialHtmlOverflow; |
|||
if (lockedAppShell) { |
|||
lockedAppShell.style.overflowY = initialAppShellOverflow; |
|||
lockedAppShell.style.touchAction = initialAppShellTouchAction; |
|||
lockedAppShell = null; |
|||
} |
|||
if (!bodyHadDropdownClass) { |
|||
document.body.classList.remove("dropdown-open"); |
|||
} |
|||
if (activeKeyboardInputCount === 0) { |
|||
document.body.classList.remove("question-input-open"); |
|||
} |
|||
} |
|||
}; |
|||
}, [isOpen]); |
|||
} |
|||
|
|||
const NON_KEYBOARD_INPUT_TYPES = new Set([ |
|||
"button", |
|||
"checkbox", |
|||
"color", |
|||
"file", |
|||
"hidden", |
|||
"image", |
|||
"radio", |
|||
"range", |
|||
"reset", |
|||
"submit", |
|||
]); |
|||
|
|||
function isKeyboardInputTarget(target: EventTarget | null): boolean { |
|||
if (target instanceof HTMLTextAreaElement) { |
|||
return !target.disabled && !target.readOnly; |
|||
} |
|||
if (target instanceof HTMLInputElement) { |
|||
const type = (target.getAttribute("type") ?? "text").toLowerCase(); |
|||
return ( |
|||
!NON_KEYBOARD_INPUT_TYPES.has(type) && |
|||
!target.disabled && |
|||
!target.readOnly |
|||
); |
|||
} |
|||
if (target instanceof HTMLElement) { |
|||
return target.isContentEditable; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
function syncQuestionInputOpenClass() { |
|||
if (activeSheetCount > 0 || activeKeyboardInputCount > 0) { |
|||
document.body.classList.add("question-input-open"); |
|||
} else { |
|||
document.body.classList.remove("question-input-open"); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Moves the active question up (same animation the dropdown sheet uses) while |
|||
* a keyboard input is focused, so the mobile keyboard cannot cover it. |
|||
*/ |
|||
export function useQuestionInputFocusSync() { |
|||
useEffect(() => { |
|||
const handleFocusIn = (event: FocusEvent) => { |
|||
if (!isKeyboardInputTarget(event.target)) return; |
|||
activeKeyboardInputCount += 1; |
|||
syncQuestionInputOpenClass(); |
|||
}; |
|||
const handleFocusOut = (event: FocusEvent) => { |
|||
if (!isKeyboardInputTarget(event.target)) return; |
|||
activeKeyboardInputCount = Math.max(0, activeKeyboardInputCount - 1); |
|||
syncQuestionInputOpenClass(); |
|||
}; |
|||
|
|||
document.addEventListener("focusin", handleFocusIn); |
|||
document.addEventListener("focusout", handleFocusOut); |
|||
return () => { |
|||
document.removeEventListener("focusin", handleFocusIn); |
|||
document.removeEventListener("focusout", handleFocusOut); |
|||
activeKeyboardInputCount = 0; |
|||
if (activeSheetCount === 0) { |
|||
document.body.classList.remove("question-input-open"); |
|||
} |
|||
}; |
|||
}, []); |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue