From 3f4095e61571a2846f01d74e559c16bf81c47d5b Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Wed, 7 Oct 2026 23:14:34 +0300 Subject: [PATCH] =?UTF-8?q?refactor(hareket):=20sihirbaz=20ad=C4=B1m=20ge?= =?UTF-8?q?=C3=A7i=C5=9Fi=20sabitleri=20lib/adim-gecisi.ts'e;=20soru=20for?= =?UTF-8?q?mu=20ayn=C4=B1=20ge=C3=A7i=C5=9Fle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../rapor/components/sohbet-anketi.tsx | 106 +++++++++++++----- .../sihirbaz/components/sihirbaz-adimlar.tsx | 45 ++------ src/lib/adim-gecisi.ts | 65 +++++++++++ 3 files changed, 148 insertions(+), 68 deletions(-) create mode 100644 src/lib/adim-gecisi.ts diff --git a/src/features/rapor/components/sohbet-anketi.tsx b/src/features/rapor/components/sohbet-anketi.tsx index e972acd..3636519 100644 --- a/src/features/rapor/components/sohbet-anketi.tsx +++ b/src/features/rapor/components/sohbet-anketi.tsx @@ -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(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) { 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({ )} /> - {anket.sorular.map((s) => ( - - {s.soru} - {s.aciklama ? {s.aciklama} : null} - - {s.secenekler.map((o) => ( - - {o.etiket} - {o.aciklama ? ( - {o.aciklama} - ) : null} - - ))} - {s.serbest ? ( - - ) : null} - - - {s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."} - - - ))} + {/* 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. */} +
+ {anket.sorular.map((s, i) => { + const buAktif = s.ad === aktif; + return ( + + + + ); + })} +
diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx index 864c6bd..52caba2 100644 --- a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx +++ b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx @@ -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({

+ {/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */} ({ - 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)} >

diff --git a/src/lib/adim-gecisi.ts b/src/lib/adim-gecisi.ts new file mode 100644 index 0000000..7096570 --- /dev/null +++ b/src/lib/adim-gecisi.ts @@ -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 }, + }; +}