v2 #10
@@ -9,10 +9,21 @@
|
||||
// danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey
|
||||
// yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan
|
||||
// aynı hâlde döner).
|
||||
//
|
||||
// Sorular arası geçiş sihirbaz adımlarıyla birebir aynı yoldan (lib/adim-gecisi.ts):
|
||||
// ileri giderken yeni soru sağdan gelir, eskisi sola çıkar; geri dönerken
|
||||
// tersi; boy ölçülen yüksekliğe yayla uzar. İlkel pasif soruyu `hidden`
|
||||
// yapar; burada `hidden={false}` ile görünürlük bize geçer (mergeProps'ta
|
||||
// kullanıcı prop'u kazanır — @shadcn/react chunk-HBS6WEDP.js `b`), pasif
|
||||
// sorular mutlak konumda saydam durur ve ilkelin verdiği `inert`i korur;
|
||||
// DOM'da kaldıkları için gönderimde FormData önceki cevapları da toplar.
|
||||
|
||||
import { useId } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { Check, MessageSquareText } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
|
||||
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
|
||||
import {
|
||||
Questionnaire,
|
||||
QuestionnaireActions,
|
||||
@@ -43,6 +54,12 @@ export function SohbetAnketi({
|
||||
onGonder: (cevaplar: AnketCevaplari) => void;
|
||||
}) {
|
||||
const kimlik = useId();
|
||||
// Aktif soru bizde (kontrollü ilkel): geçiş yönü sorunun sırasından çıkar.
|
||||
const [aktif, setAktif] = useState<string | undefined>(anket.sorular[0]?.ad);
|
||||
const [gecisBasladi, setGecisBasladi] = useState(false);
|
||||
// Yalnız aktif sorunun fieldset'i ölçülür; ref soru değişince ona geçer.
|
||||
const [olcumRef, olculer] = useMeasure();
|
||||
const hareketiAzalt = !!useReducedMotion();
|
||||
|
||||
if (durum === "cevaplandi" || durum === "kapandi") {
|
||||
return (
|
||||
@@ -63,6 +80,11 @@ export function SohbetAnketi({
|
||||
choices: s.secenekler.map((o) => ({ value: o.deger })),
|
||||
}));
|
||||
|
||||
const aktifIndeks = anket.sorular.findIndex((s) => s.ad === aktif);
|
||||
const yukseklik = gecisBasladi && olculer.height > 0 ? olculer.height : "auto";
|
||||
const varyantlar = adimGecisiVaryantlari({ hareketiAzalt, yukseklik });
|
||||
const gecis = adimGecisi(hareketiAzalt);
|
||||
|
||||
function gonder(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (durum === "gonderiliyor") return;
|
||||
@@ -86,7 +108,11 @@ export function SohbetAnketi({
|
||||
aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined}
|
||||
aria-label={anket.baslik ? undefined : "Danışmanın sorusu"}
|
||||
className="rounded-2xl border border-slate-200 bg-card p-4"
|
||||
defaultItem={anket.sorular[0]?.ad}
|
||||
item={aktif}
|
||||
onItemChange={(ad) => {
|
||||
setGecisBasladi(true);
|
||||
setAktif(ad);
|
||||
}}
|
||||
items={items}
|
||||
shortcuts="letters"
|
||||
onSubmit={gonder}
|
||||
@@ -103,34 +129,54 @@ export function SohbetAnketi({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{anket.sorular.map((s) => (
|
||||
<QuestionnaireItem
|
||||
key={s.ad}
|
||||
name={s.ad}
|
||||
multiple={s.coklu === true}
|
||||
required={s.zorunlu !== false}
|
||||
disabled={durum === "gonderiliyor"}
|
||||
>
|
||||
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
|
||||
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
|
||||
<QuestionnaireChoices>
|
||||
{s.secenekler.map((o) => (
|
||||
<QuestionnaireChoice key={o.deger} value={o.deger}>
|
||||
<span>{o.etiket}</span>
|
||||
{o.aciklama ? (
|
||||
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription>
|
||||
) : null}
|
||||
</QuestionnaireChoice>
|
||||
))}
|
||||
{s.serbest ? (
|
||||
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} />
|
||||
) : null}
|
||||
</QuestionnaireChoices>
|
||||
<QuestionnaireError>
|
||||
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
|
||||
</QuestionnaireError>
|
||||
</QuestionnaireItem>
|
||||
))}
|
||||
{/* Kayan sorular taşmasın diye kırpılır; 4 px'lik negatif kenar +
|
||||
fieldset'teki p-1 odak halkasına (ring-3) yer bırakır. */}
|
||||
<div className="relative -m-1 overflow-hidden">
|
||||
{anket.sorular.map((s, i) => {
|
||||
const buAktif = s.ad === aktif;
|
||||
return (
|
||||
<motion.div
|
||||
key={s.ad}
|
||||
// Aktiften önceki sorular sola, sonrakiler sağa park eder;
|
||||
// böylece ileri geçişte yeni soru sağdan, geri dönüşte soldan gelir.
|
||||
custom={i < aktifIndeks ? 1 : -1}
|
||||
variants={varyantlar}
|
||||
initial={false}
|
||||
animate={buAktif ? "center" : "exit"}
|
||||
transition={gecis}
|
||||
>
|
||||
<QuestionnaireItem
|
||||
ref={buAktif ? olcumRef : undefined}
|
||||
name={s.ad}
|
||||
multiple={s.coklu === true}
|
||||
required={s.zorunlu !== false}
|
||||
disabled={durum === "gonderiliyor"}
|
||||
hidden={false}
|
||||
className="p-1"
|
||||
>
|
||||
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
|
||||
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
|
||||
<QuestionnaireChoices>
|
||||
{s.secenekler.map((o) => (
|
||||
<QuestionnaireChoice key={o.deger} value={o.deger}>
|
||||
<span>{o.etiket}</span>
|
||||
{o.aciklama ? (
|
||||
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription>
|
||||
) : null}
|
||||
</QuestionnaireChoice>
|
||||
))}
|
||||
{s.serbest ? (
|
||||
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} />
|
||||
) : null}
|
||||
</QuestionnaireChoices>
|
||||
<QuestionnaireError>
|
||||
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
|
||||
</QuestionnaireError>
|
||||
</QuestionnaireItem>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<QuestionnaireActions>
|
||||
<QuestionnairePrevious size="sm" />
|
||||
<QuestionnaireSkip size="sm" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import useMeasure from "react-use-measure";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IlSecimHaritasi } from "./il-secim-haritasi";
|
||||
import { normalizeIlAdi } from "@/lib/harita";
|
||||
@@ -249,47 +250,15 @@ export function SihirbazAdimlar({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */}
|
||||
<TransitionPanel
|
||||
activeIndex={adim}
|
||||
custom={yon}
|
||||
variants={{
|
||||
enter: (gecisYonu: number) => ({
|
||||
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
height: panelYuksekligi,
|
||||
position: "relative",
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
height: panelYuksekligi,
|
||||
},
|
||||
exit: (gecisYonu: number) => ({
|
||||
zIndex: 0,
|
||||
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
}),
|
||||
}}
|
||||
transition={
|
||||
hareketiAzalt
|
||||
? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x
|
||||
// varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır.
|
||||
{
|
||||
opacity: { duration: 0.12 },
|
||||
x: { duration: 0 },
|
||||
height: { duration: 0 },
|
||||
}
|
||||
: {
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
height: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}
|
||||
}
|
||||
variants={adimGecisiVaryantlari({
|
||||
hareketiAzalt: !!hareketiAzalt,
|
||||
yukseklik: panelYuksekligi,
|
||||
})}
|
||||
transition={adimGecisi(!!hareketiAzalt)}
|
||||
>
|
||||
<div ref={panelRef} className="px-px pb-2">
|
||||
<h3 className="font-heading text-xl font-bold text-slate-900">
|
||||
|
||||
65
src/lib/adim-gecisi.ts
Normal file
65
src/lib/adim-gecisi.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Adım adım ilerleyen panellerin ortak geçişi (DESIGN.md §6): sihirbazın
|
||||
// alan → il → öncelik adımları (sihirbaz-adimlar.tsx, TransitionPanel) ile
|
||||
// danışmanın soru formundaki sorular (sohbet-anketi.tsx) aynı yoldan gelir
|
||||
// gider — yeni adım ileri giderken sağdan, geri dönerken soldan kayar; boy
|
||||
// ölçülen yüksekliğe yayla uzar. Sabitler yalnız burada; iki yerde de bu iki
|
||||
// fonksiyon çağrılır, kopyası yazılmaz. Yay sekmesiz (stiffness 300 /
|
||||
// damping 30), opaklık 0,2 s. "Hareketi azalt"ta kayma ve boy yayı yok,
|
||||
// yalnız 0,12 s'lik çapraz solma.
|
||||
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
|
||||
/**
|
||||
* enter / center / exit varyantları; `custom` geçiş yönüdür (1 ileri, -1
|
||||
* geri). Giren panel akışta (`relative`) durur, çıkan panel mutlak konuma
|
||||
* alınır ki ikisi aynı anda üst üste kayabilsin. `yukseklik` ölçülen boy
|
||||
* (ilk ölçümden önce "auto").
|
||||
*/
|
||||
export function adimGecisiVaryantlari({
|
||||
hareketiAzalt,
|
||||
yukseklik,
|
||||
}: {
|
||||
hareketiAzalt: boolean;
|
||||
yukseklik: number | "auto";
|
||||
}): Variants {
|
||||
return {
|
||||
enter: (gecisYonu: number) => ({
|
||||
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
height: yukseklik,
|
||||
position: "relative",
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
height: yukseklik,
|
||||
position: "relative",
|
||||
},
|
||||
exit: (gecisYonu: number) => ({
|
||||
zIndex: 0,
|
||||
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Geçişin zamanlaması; azaltılmış harekette x/height açıkça sıfırlanır ki
|
||||
* varsayılan yaya düşüp oynamasın. */
|
||||
export function adimGecisi(hareketiAzalt: boolean): Transition {
|
||||
return hareketiAzalt
|
||||
? {
|
||||
opacity: { duration: 0.12 },
|
||||
x: { duration: 0 },
|
||||
height: { duration: 0 },
|
||||
}
|
||||
: {
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
height: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user