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
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;
|