"use client"; // Alt sayfa (bottom sheet) — DESIGN.md §4.10. Ayrıntı her zaman alttan // açılan bu sayfadadır. Sayfa parmağı birebir izler: aşağı çekince kapanır, // en üst durağın ötesine çekince lastik gibi direnir, bırakınca bırakma // hızını taşıyarak en yakın durağa oturur; kapanırken yakalanıp geri // açılabilir. Arkadaki perde zamanlayıcıyla değil sayfanın konumuyla koyulaşır. // // Kaynak: Arc UI "Bottom sheet" (uiarc.dev, 7 Eki 2026); eski vaul çekmecesinin // yerini aldı. Bizdeki farklar: // - Boy içeriğe göredir (Arc'ta sabit); `duraklar` ekran yüksekliğinin oranıdır // ve içerik daha kısaysa içeriğin boyunda kalır. Varsayılan tek durak (0,92). // - Parçalı kullanım: başlık, çarpı ve gövde çağıranındır (§4.10 ortalı başlık, // §4.3 çarpı). Kaydırılan alan `AltSayfaGovde` olmalıdır — sayfa onun // kaydırmasıyla el sıkışır (en üstteyken aşağı çekiş sayfayı indirir). // - Yaylar sekmesiz (§6); hareketi azalt'ta kayma yok, yalnız opaklık. import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type WheelEvent, } from "react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { AnimatePresence, animate, motion, useMotionValue, usePresence, useReducedMotion, useTransform, } from "motion/react"; import { EGRI, SURE, YAY, birakmaHizi, lastik, lastikTersi } from "@/lib/hareket"; import { cn } from "@/lib/utils"; type Durak = number | "kapali"; type Surukleme = { basY: number; koken: number; baslangic: number; oynadi: boolean; ornekler: { t: number; v: number }[]; }; /** Kapalıyken gölge de görünmesin diye kenarın altında bırakılan pay (px). */ const KAPALI_PAYI = 40; /** Bırakılan sayfanın, hızıyla kaç saniye daha süzüleceği varsayılır. */ const SUZULME = 0.2; /** Bundan hızlı bırakış (px/sn) yönünde en az bir durak ilerler. */ const FISKE = 320; /** En üst durağın ötesine çekişin yaklaşıp geçemediği sınır (px). */ const ESNEME = 120; /** En alçak durakta perdenin tam koyuluğa oranı. */ const ALT_KOYULUK = 0.78; /** Ayrılış gelişten kısadır; fiske aynı yayda kendi hızını taşır. */ const AYRILMA = { ...YAY.akici, visualDuration: 0.3 }; const SOLMA = { duration: SURE.hizli, ease: EGRI.standart }; const sinirla = (v: number, alt: number, ust: number) => Math.min(ust, Math.max(alt, v)); const ekranBoyu = () => (typeof window === "undefined" ? 800 : window.innerHeight); const KokBaglami = createContext<{ acik: boolean; kapat: () => void } | null>(null); type Tutamak = { onPointerDown: (e: ReactPointerEvent) => void; onPointerMove: (e: ReactPointerEvent) => void; onPointerUp: (e: ReactPointerEvent) => void; onPointerCancel: (e: ReactPointerEvent) => void; }; const SayfaBaglami = createContext<{ govdeBagla: (el: HTMLDivElement | null) => void; govdeTekerlek: (e: WheelEvent) => void; govdeOdak: (e: FocusEvent) => void; tutamak: Tutamak; } | null>(null); function AltSayfa({ open: disAcik, defaultOpen = false, onOpenChange, children, }: { open?: boolean; defaultOpen?: boolean; onOpenChange?: (acik: boolean) => void; children: React.ReactNode; }) { const [icAcik, setIcAcik] = useState(defaultOpen); const acik = disAcik ?? icAcik; const setAcik = useCallback( (sonraki: boolean) => { if (disAcik === undefined) setIcAcik(sonraki); onOpenChange?.(sonraki); }, [disAcik, onOpenChange], ); const baglam = useMemo(() => ({ acik, kapat: () => setAcik(false) }), [acik, setAcik]); return ( {children} ); } function AltSayfaTetik(props: ComponentProps) { return ; } function AltSayfaKapat(props: ComponentProps) { return ; } type IcerikProps = Omit, "asChild" | "forceMount"> & { /** Dinlenme boyları, ekran yüksekliğinin oranı olarak. En büyüğü sayfanın tavanıdır. */ duraklar?: number[]; /** Sıralı durakların hangisinde açılacağı; verilmezse en üstteki. */ ilkDurak?: number; /** Açılış yayı yerine oturunca `true` ile çağrılır. */ onOturdu?: (acik: boolean) => void; }; function AltSayfaIcerik(props: IcerikProps) { const kok = useContext(KokBaglami); if (!kok) throw new Error("AltSayfaIcerik, AltSayfa içinde kullanılmalı."); return ( // Sayfa ayrılırken monte kalır: kapanış yarı yolda yakalanıp geri alınabilir. {kok.acik ? ( ) : null} ); } function Sayfa({ className, children, duraklar = [0.92], ilkDurak, onOturdu, kapat, onPointerDownOutside, onInteractOutside, onKeyDown, ...props }: IcerikProps & { kapat: () => void }) { const [burada, kaldirilabilir] = usePresence(); const azalt = useReducedMotion() ?? false; const durakAnahtari = duraklar.join(","); const kesirler = useMemo( () => durakAnahtari .split(",") .map(Number) .filter((d) => d > 0) .sort((a, b) => a - b), [durakAnahtari], ); const ust = kesirler.length - 1; const cokDurakli = kesirler.length > 1; const [durak, setDurak] = useState(() => sinirla(Math.round(ilkDurak ?? ust), 0, ust)); const [duyuru, setDuyuru] = useState(""); const sayfaRef = useRef(null); const govdeRef = useRef(null); /** Sayfanın en üst durağının altındaki kayması (px). İlk kare parlamasın diye çok aşağıdan başlar. */ const y = useMotionValue(4000); const boy = useMotionValue(0); const varlik = useMotionValue(1); const durakRef = useRef(durak); const hedef = useRef(durak); const surukleme = useRef(null); const varinca = useRef<(() => void) | undefined>(undefined); const tiklamayiYut = useRef(false); const kuruldu = useRef(false); const oturduRef = useRef(onOturdu); /** Sayfanın en son ne zaman girmeye/çıkmaya başladığı: ayrılırken başlayan basış, geri açılan sayfayı kapatmasın. */ const varlikDegisimi = useRef({ burada, an: 0 }); useLayoutEffect(() => { oturduRef.current = onOturdu; }, [onOturdu]); useLayoutEffect(() => { varlikDegisimi.current = { burada, an: performance.now() }; }, [burada]); const durakPx = useCallback( (i: number) => Math.min(kesirler[i] * ekranBoyu(), boy.get()), [boy, kesirler], ); const ofset = useCallback( (d: Durak) => (d === "kapali" ? boy.get() + KAPALI_PAYI : boy.get() - durakPx(d)), [boy, durakPx], ); /** Sayfa tam açık değilken gövde kaydırmaz; çekiş sayfayı taşır (bkz. AltSayfaGovde). */ const kisikligiIsaretle = useCallback(() => { sayfaRef.current?.toggleAttribute("data-kisik", ofset(durakRef.current) > 0.5); }, [ofset]); const tamAcik = useCallback(() => ofset(durakRef.current) <= 0.5, [ofset]); // Perde sayfanın konumunun fonksiyonudur: sürüklemeyle koyulaşır ve açılır. const perde = useTransform([y, boy, varlik], ([kayma, tam, gorunen]: number[]) => { if (!tam) return 0; const alt = tam - Math.min(kesirler[0] * ekranBoyu(), tam); const kapali = tam + KAPALI_PAYI; const taban = alt > 0 ? ALT_KOYULUK : 1; const koyuluk = kayma <= alt ? 1 - (1 - taban) * (alt ? kayma / alt : 0) : taban * (1 - (kayma - alt) / (kapali - alt)); return sinirla(koyuluk, 0, 1) * gorunen; }); // Her hareket tek motion değerini bulunduğu yerden yeniden hedefler. const git = useCallback( (d: Durak, hiz?: number, sonra?: () => void) => { hedef.current = d; varinca.current = sonra; const nokta = ofset(d); const bitti = () => { const cagri = varinca.current; varinca.current = undefined; cagri?.(); }; if (azalt) { y.jump(nokta); bitti(); return; } animate(y, nokta, { ...(d === "kapali" ? AYRILMA : YAY.akici), ...(hiz === undefined ? {} : { velocity: hiz }), onComplete: bitti, }); }, [azalt, ofset, y], ); const dinlen = useCallback( (d: number, hiz?: number) => { if (d !== durakRef.current) { durakRef.current = d; setDurak(d); if (cokDurakli) setDuyuru(d === ust ? "Sayfa genişledi" : "Sayfa küçüldü"); } kisikligiIsaretle(); // Küçülürken içerik başa döner: kısık hâl hep en üstü gösterir. const govde = govdeRef.current; if (!tamAcik() && govde && govde.scrollTop > 0) { govde.scrollTo({ top: 0, behavior: azalt ? "auto" : "smooth" }); } git(d, hiz); }, [azalt, cokDurakli, git, kisikligiIsaretle, tamAcik, ust], ); // Giriş, çıkış ve çıkarken yeniden açılma aynı motion değerini yeniden hedefler. useLayoutEffect(() => { const sayfa = sayfaRef.current; if (!sayfa) return; if (!kuruldu.current) { kuruldu.current = true; boy.set(sayfa.offsetHeight); y.jump(ofset("kapali")); if (azalt) { y.jump(ofset(durakRef.current)); varlik.jump(0); } kisikligiIsaretle(); } const oturdu = () => oturduRef.current?.(true); if (burada) { if (azalt) { y.jump(ofset(durakRef.current)); animate(varlik, 1, { ...SOLMA, onComplete: oturdu }); return; } varlik.jump(1); git(durakRef.current, undefined, oturdu); return; } if (azalt) { animate(varlik, 0, { ...SOLMA, onComplete: kaldirilabilir }); return; } // Fiske çıkışı kendi hızıyla başlatmıştır; yalnız kaldırma onu bekler. if (hedef.current === "kapali" && y.isAnimating()) varinca.current = kaldirilabilir; else git("kapali", undefined, kaldirilabilir); }, [azalt, boy, burada, git, kaldirilabilir, kisikligiIsaretle, ofset, varlik, y]); // İçerik (geç gelen veri) ya da ekran (döndürme, mobil araç çubuğu) değişince // duraklar da kayar: sayfa durağında kalır, uçuş varsa yeniden hedeflenir. useEffect(() => { const sayfa = sayfaRef.current; if (!sayfa || typeof ResizeObserver === "undefined") return; const yenidenOlc = () => { boy.set(sayfa.offsetHeight); const d = hedef.current; kisikligiIsaretle(); if (surukleme.current || d === "kapali") return; const nokta = ofset(d); if (Math.abs(nokta - y.get()) < 0.5 && !y.isAnimating()) return; if (y.isAnimating()) git(d, undefined, varinca.current); else y.jump(nokta); }; const gozlemci = new ResizeObserver(yenidenOlc); gozlemci.observe(sayfa); window.addEventListener("resize", yenidenOlc); return () => { gozlemci.disconnect(); window.removeEventListener("resize", yenidenOlc); }; }, [boy, git, kisikligiIsaretle, ofset, y]); const suruklemeBasla = useCallback( (clientY: number, an: number) => { // Sayfayı uçarken bile bulunduğu yerden yakala; esnemiş sayfa parmak // mesafesine geri çevrilir ki hiçbir şey sıçramasın. y.stop(); const simdiki = y.get(); surukleme.current = { basY: clientY, koken: simdiki < 0 ? -lastikTersi(-simdiki, ESNEME) : simdiki, baslangic: durakRef.current, oynadi: false, ornekler: [{ t: an, v: clientY }], }; }, [y], ); const suruklemeSur = useCallback( (clientY: number, an: number) => { const s = surukleme.current; if (!s) return; const fark = clientY - s.basY; if (!s.oynadi) { if (Math.abs(fark) < 3) return; s.oynadi = true; sayfaRef.current?.setAttribute("data-surukleniyor", ""); } const ham = s.koken + fark; // Aşağı birebir izler (kapatabilsin); en üstün ötesi lastik gibi direnir. y.set(ham < 0 ? -lastik(-ham, ESNEME) : ham); s.ornekler.push({ t: an, v: clientY }); if (s.ornekler.length > 12) s.ornekler.shift(); }, [y], ); const suruklemeBitir = useCallback( (an: number) => { const s = surukleme.current; surukleme.current = null; sayfaRef.current?.removeAttribute("data-surukleniyor"); if (!s) return; if (!s.oynadi) { const d = hedef.current; if (d !== "kapali") git(d, undefined, varinca.current); return; } tiklamayiYut.current = true; const simdiki = y.get(); // Esnemiş sayfa parmağın hızını almadan döner; esnemenin ötesine fırlamaz. const hiz = simdiki < 0 ? 0 : birakmaHizi(s.ornekler, an); const varis = simdiki + hiz * SUZULME; const adaylar: Durak[] = [...kesirler.map((_, i) => i), "kapali"]; let secilen = adaylar.reduce((iyi, d) => Math.abs(ofset(d) - varis) < Math.abs(ofset(iyi) - varis) ? d : iyi, ); // Hızlı fiske en az bir durak yol alır; en alçaktan aşağı fiske kapatır. if (Math.abs(hiz) > FISKE && secilen === s.baslangic) { const buradaPx = durakPx(s.baslangic); if (hiz < 0) { const yukari = kesirler.findIndex((_, i) => durakPx(i) > buradaPx + 0.5); secilen = yukari === -1 ? s.baslangic : yukari; } else { let asagi = -1; for (let i = kesirler.length - 1; i >= 0; i--) { if (durakPx(i) < buradaPx - 0.5) { asagi = i; break; } } secilen = asagi === -1 ? "kapali" : asagi; } } // Çıkış bu karede parmağın hızıyla başlar; kapanış yalnız inmesini bekler. if (secilen === "kapali") { if (!azalt) git("kapali", hiz); kapat(); return; } dinlen(secilen, hiz); }, [azalt, dinlen, durakPx, git, kapat, kesirler, ofset, y], ); // Gövdedeki dokunma: sayfa tam açık değilken çekiş sayfayı taşır; tam // açıkken içerik kaydırır, en üstteyken aşağı çekiş yine sayfayı indirir. useEffect(() => { const govde = govdeRef.current; if (!govde) return; let dokunus: { x: number; y: number; kip: "bekliyor" | "sayfa" | "yerel" } | null = null; const basla = (e: TouchEvent) => { const t = e.touches[0]; const yoksay = e.target instanceof Element && e.target.closest("[data-alt-sayfa-yoksay]"); dokunus = e.touches.length === 1 && t && !yoksay ? { x: t.clientX, y: t.clientY, kip: "bekliyor" } : null; }; const sur = (e: TouchEvent) => { const t = e.touches[0]; if (!dokunus || !t || e.touches.length !== 1) return; const dx = t.clientX - dokunus.x; const dy = t.clientY - dokunus.y; if (dokunus.kip === "bekliyor") { if (Math.hypot(dx, dy) < 4) return; const dikey = Math.abs(dy) >= Math.abs(dx); if (tamAcik()) { const kayar = govde.scrollHeight > govde.clientHeight + 1; dokunus.kip = dikey && (!kayar || (govde.scrollTop <= 0 && dy > 0)) ? "sayfa" : "yerel"; } else { dokunus.kip = dikey ? "sayfa" : "yerel"; } if (dokunus.kip === "sayfa") suruklemeBasla(t.clientY, e.timeStamp); } if (dokunus.kip !== "sayfa") return; if (e.cancelable) e.preventDefault(); suruklemeSur(t.clientY, e.timeStamp); }; const bitir = (e: TouchEvent) => { if (dokunus?.kip === "sayfa") suruklemeBitir(e.timeStamp); dokunus = null; }; govde.addEventListener("touchstart", basla, { passive: true }); govde.addEventListener("touchmove", sur, { passive: false }); govde.addEventListener("touchend", bitir); govde.addEventListener("touchcancel", bitir); return () => { govde.removeEventListener("touchstart", basla); govde.removeEventListener("touchmove", sur); govde.removeEventListener("touchend", bitir); govde.removeEventListener("touchcancel", bitir); }; }, [suruklemeBasla, suruklemeBitir, suruklemeSur, tamAcik]); const baglam = useMemo(() => { const tutamak: Tutamak = { onPointerDown(e) { const hedefOge = e.target instanceof Element ? e.target : null; if (e.button !== 0 || !e.isPrimary) return; if ( hedefOge?.closest("button, a, input, select, textarea, [role='button']") && !hedefOge.closest("[data-tutamac]") ) { return; } suruklemeBasla(e.clientY, e.timeStamp); // Hızlı fiske ilk hareket gelmeden alandan çıkabilir: düğme dışında // işaretçi hemen yakalanır. if (!hedefOge?.closest("[data-tutamac]")) { e.currentTarget.setPointerCapture(e.pointerId); } }, // Tutamaç düğmesinde işaretçi yalnız gerçekten oynayınca yakalanır; düz tık düğmeye düşer. onPointerMove(e) { if (!surukleme.current) return; if (e.pointerType === "mouse" && e.buttons === 0) { suruklemeBitir(e.timeStamp); return; } suruklemeSur(e.clientY, e.timeStamp); if (surukleme.current.oynadi && !e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.setPointerCapture(e.pointerId); } }, onPointerUp(e) { if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } suruklemeBitir(e.timeStamp); }, onPointerCancel(e) { if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } suruklemeBitir(e.timeStamp); }, }; return { govdeBagla: (el: HTMLDivElement | null) => { govdeRef.current = el; }, // Henüz ekran dışındaki içeriğe kaydırmak ya da sekmeyle girmek sayfayı sonuna kadar açar. govdeTekerlek: (e: WheelEvent) => { if (!tamAcik() && e.deltaY > 4 && !surukleme.current) dinlen(ust); }, govdeOdak: (e: FocusEvent) => { if (tamAcik() || !(e.target instanceof Element)) return; if (e.target.getBoundingClientRect().bottom > window.innerHeight - 8) dinlen(ust); }, tutamak, }; }, [dinlen, suruklemeBasla, suruklemeBitir, suruklemeSur, tamAcik, ust]); function tutamacTik() { if (tiklamayiYut.current) { tiklamayiYut.current = false; return; } dinlen(durakRef.current === ust ? 0 : ust); } function tutamacTus(e: KeyboardEvent) { const simdiki = durakRef.current; const sonraki = e.key === "ArrowUp" ? Math.min(simdiki + 1, ust) : e.key === "ArrowDown" ? Math.max(simdiki - 1, 0) : e.key === "Home" ? ust : e.key === "End" ? 0 : null; if (sonraki === null) return; e.preventDefault(); dinlen(sonraki); } const genis = durak === ust; return ( <> { // Odak sayfanın kendisine gider: klavye açılmaz, sayfa kaymaz. e.preventDefault(); sayfaRef.current?.focus({ preventScroll: true }); }} onPointerDownOutside={(e) => { onPointerDownOutside?.(e); const { burada: simdiBurada, an } = varlikDegisimi.current; if (!simdiBurada || e.detail.originalEvent.timeStamp < an) e.preventDefault(); }} onInteractOutside={(e) => { onInteractOutside?.(e); // Bildirimdeki "Geri al"a basmak sayfayı kapatmaz. if (e.target instanceof Element && e.target.closest("[data-bildirim-yigini]")) { e.preventDefault(); } }} onKeyDown={onKeyDown} {...props} >
{cokDurakli ? ( ) : ( )}
{children} {duyuru}
); } /** * Sayfanın kaydırılan alanı. Sayfa tam açık değilken kaydırmaz (çekiş sayfayı * taşır); içinde kendi sürükleme hareketi olan öğe `data-alt-sayfa-yoksay` alır. */ function AltSayfaGovde({ className, ...props }: ComponentProps<"div">) { const sayfa = useContext(SayfaBaglami); return (
); } /** Kaydırmayan başlık alanı: buradan da tutulup çekilir (düğme ve linkler hariç). */ function AltSayfaSurukle({ className, ...props }: ComponentProps<"div">) { const sayfa = useContext(SayfaBaglami); return (
); } function AltSayfaBaslik({ className, ...props }: ComponentProps) { return ( ); } function AltSayfaAciklama({ className, ...props }: ComponentProps) { return ( ); } export { AltSayfa, AltSayfaTetik, AltSayfaKapat, AltSayfaIcerik, AltSayfaGovde, AltSayfaSurukle, AltSayfaBaslik, AltSayfaAciklama, };