refactor(hareket): sihirbaz adım geçişi sabitleri lib/adim-gecisi.ts'e; soru formu aynı geçişle

- adimGecisiVaryantlari / adimGecisi tek kaynak (spring 300/30, opaklık 0,2 s;
  hareketi azalt'ta 0,12 s çapraz solma). Sihirbaz davranışı aynı; center
  varyantına `position: relative` eklendi ki geri dönen panel mutlak kalmasın.
- SohbetAnketi kontrollü (item/onItemChange); her soru motion.div içinde,
  pasif sorular hidden={false} + mutlak/saydam park eder (ilkelin inert'i
  kalır), aktif fieldset useMeasure ile ölçülüp boy yayla uzar. Çıkış animasyonu
  da çalışır: mergeProps'ta kullanıcı prop'u kazanıyor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-07 23:14:34 +03:00
parent f08a26a022
commit 3f4095e615
3 changed files with 148 additions and 68 deletions

View File

@@ -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" />