You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

338 lines
11 KiB

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { ExplanationUiFont } from "./explanation-ui-font";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionDropdownProps = {
question: QuestionField;
disabled?: boolean;
};
export function QuestionDropdown({
question,
disabled,
}: QuestionDropdownProps) {
const { dictionary: t } = 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 [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Lock page scroll when dropdown is open
useEffect(() => {
if (isOpen) {
document.body.classList.add("dropdown-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
} else {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
}
return () => {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
};
}, [isOpen]);
// Prevent wheel scroll from escaping the options list
const handleListWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
const el = listRef.current;
if (!el) return;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom =
el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0;
if (atTop || atBottom) {
e.preventDefault();
}
}, []);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
const options = question.options || [];
const showSearch = (() => {
if (question.extras?.noSearch) return false;
return options.length > 10;
})();
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 displayLabel = isMulti
? selectedList.length > 0
? selectedList.map(getCleanLabel).join(", ")
: question.extras?.placeHolder || "Select"
: singleValue
? getCleanLabel(singleValue)
: question.extras?.placeHolder || "Select";
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,
);
};
return (
<div
ref={containerRef}
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 */}
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
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>
{/* Dropdown Options Panel */}
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{/* Search Input Bar */}
{showSearch ? (
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<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"
name="search_field"
autoComplete="one-time-code"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
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"
>
</button>
) : null}
</div>
) : null}
{/* Options List */}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
>
{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 {
setAnswerValue(question, option.id);
setIsOpen(false);
}
}}
className="flex w-full items-start gap-3 text-start cursor-pointer group/opt py-0.5"
>
{/* Option Indicator Icon */}
{isMulti ? (
/* Multi-select / Checkbox style */
<div
className={[
"size-[20px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
) : (
/* Single-select / Radio style */
<div
className={[
"size-[20px] shrink-0 rounded-full transition-all duration-150 mt-0.5",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
)}
<span className="text-[15px] text-[#181818] leading-tight 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="font-bold">{option.label}</span>
)}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
)}
</div>
);
}
export default QuestionDropdown;