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
-
158src/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
-
6src/translations/locales/ar.json
-
6src/translations/locales/az.json
-
6src/translations/locales/bn.json
-
6src/translations/locales/da.json
-
6src/translations/locales/de.json
-
6src/translations/locales/en.json
-
6src/translations/locales/es.json
-
6src/translations/locales/fa.json
-
6src/translations/locales/fr.json
-
6src/translations/locales/gu.json
-
6src/translations/locales/ha.json
-
8src/translations/locales/he.json
-
6src/translations/locales/hi.json
-
8src/translations/locales/id.json
-
8src/translations/locales/ks.json
-
8src/translations/locales/pt.json
-
6src/translations/locales/ru.json
-
8src/translations/locales/sw.json
-
8src/translations/locales/tg.json
-
8src/translations/locales/tr.json
-
8src/translations/locales/ul.json
-
8src/translations/locales/ur.json
-
8src/translations/locales/uz.json
-
6src/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