Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
"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<HTMLDivElement>) => void;
|
||
onPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
||
onPointerUp: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
||
onPointerCancel: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
||
};
|
||
const SayfaBaglami = createContext<{
|
||
govdeBagla: (el: HTMLDivElement | null) => void;
|
||
govdeTekerlek: (e: WheelEvent<HTMLDivElement>) => void;
|
||
govdeOdak: (e: FocusEvent<HTMLDivElement>) => 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 (
|
||
<DialogPrimitive.Root open={acik} onOpenChange={setAcik}>
|
||
<KokBaglami.Provider value={baglam}>{children}</KokBaglami.Provider>
|
||
</DialogPrimitive.Root>
|
||
);
|
||
}
|
||
|
||
function AltSayfaTetik(props: ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||
return <DialogPrimitive.Trigger data-slot="alt-sayfa-tetik" {...props} />;
|
||
}
|
||
|
||
function AltSayfaKapat(props: ComponentProps<typeof DialogPrimitive.Close>) {
|
||
return <DialogPrimitive.Close data-slot="alt-sayfa-kapat" {...props} />;
|
||
}
|
||
|
||
type IcerikProps = Omit<ComponentProps<typeof DialogPrimitive.Content>, "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.
|
||
<AnimatePresence>
|
||
{kok.acik ? (
|
||
<DialogPrimitive.Portal key="alt-sayfa" forceMount>
|
||
<Sayfa {...props} kapat={kok.kapat} />
|
||
</DialogPrimitive.Portal>
|
||
) : null}
|
||
</AnimatePresence>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement>(null);
|
||
const govdeRef = useRef<HTMLDivElement | null>(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>(durak);
|
||
const surukleme = useRef<Surukleme | null>(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<HTMLDivElement>) => {
|
||
if (!tamAcik() && e.deltaY > 4 && !surukleme.current) dinlen(ust);
|
||
},
|
||
govdeOdak: (e: FocusEvent<HTMLDivElement>) => {
|
||
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<HTMLButtonElement>) {
|
||
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 (
|
||
<>
|
||
<DialogPrimitive.Overlay asChild forceMount>
|
||
<motion.div
|
||
data-slot="alt-sayfa-perde"
|
||
// Ayrılırken altındaki hiçbir şey engellenmez: tetiğe basış kapanışı geri alabilir.
|
||
className="fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-[state=closed]:pointer-events-none!"
|
||
style={{ opacity: perde }}
|
||
/>
|
||
</DialogPrimitive.Overlay>
|
||
<DialogPrimitive.Content
|
||
asChild
|
||
forceMount
|
||
onOpenAutoFocus={(e) => {
|
||
// 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}
|
||
>
|
||
<motion.div
|
||
ref={sayfaRef}
|
||
data-slot="alt-sayfa"
|
||
className={cn(
|
||
// `after`: kenarın altına uzanan yüzey — yukarı esnemede sayfanın altında boşluk görünmez.
|
||
"group/alt-sayfa fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col rounded-t-3xl border border-b-0 border-slate-200 bg-card text-slate-900 shadow-2xl outline-none will-change-transform after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-40 after:bg-card data-[state=closed]:pointer-events-none! sm:max-w-2xl",
|
||
className,
|
||
)}
|
||
style={{ y, opacity: varlik, maxHeight: `${kesirler[ust] * 100}dvh` }}
|
||
>
|
||
<div
|
||
className="flex h-6 shrink-0 cursor-grab touch-none items-end justify-center select-none group-data-[surukleniyor]/alt-sayfa:cursor-grabbing"
|
||
{...baglam.tutamak}
|
||
>
|
||
{cokDurakli ? (
|
||
<button
|
||
type="button"
|
||
data-tutamac=""
|
||
aria-expanded={genis}
|
||
onClick={tutamacTik}
|
||
onKeyDown={tutamacTus}
|
||
className="flex h-6 w-16 cursor-[inherit] items-end justify-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||
>
|
||
<span className="h-1.5 w-12 rounded-full bg-slate-200 transition-colors duration-150 group-data-[surukleniyor]/alt-sayfa:bg-slate-400" aria-hidden />
|
||
<span className="sr-only">{genis ? "Sayfayı küçült" : "Sayfayı genişlet"}</span>
|
||
</button>
|
||
) : (
|
||
<span className="h-1.5 w-12 rounded-full bg-slate-200 transition-colors duration-150 group-data-[surukleniyor]/alt-sayfa:bg-slate-400" aria-hidden />
|
||
)}
|
||
</div>
|
||
<SayfaBaglami.Provider value={baglam}>{children}</SayfaBaglami.Provider>
|
||
<span className="sr-only" role="status" aria-live="polite">
|
||
{duyuru}
|
||
</span>
|
||
</motion.div>
|
||
</DialogPrimitive.Content>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<div
|
||
ref={sayfa?.govdeBagla}
|
||
data-slot="alt-sayfa-govde"
|
||
className={cn(
|
||
"min-h-0 flex-1 overflow-y-auto overscroll-contain group-data-[kisik]/alt-sayfa:touch-none group-data-[kisik]/alt-sayfa:overflow-hidden",
|
||
className,
|
||
)}
|
||
onWheel={sayfa?.govdeTekerlek}
|
||
onFocus={sayfa?.govdeOdak}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<div
|
||
data-slot="alt-sayfa-surukle"
|
||
className={cn("touch-none select-none", className)}
|
||
{...sayfa?.tutamak}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function AltSayfaBaslik({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
|
||
return (
|
||
<DialogPrimitive.Title
|
||
data-slot="alt-sayfa-baslik"
|
||
className={cn("font-heading text-2xl font-bold text-slate-900", className)}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function AltSayfaAciklama({
|
||
className,
|
||
...props
|
||
}: ComponentProps<typeof DialogPrimitive.Description>) {
|
||
return (
|
||
<DialogPrimitive.Description
|
||
data-slot="alt-sayfa-aciklama"
|
||
className={cn("text-sm text-slate-500", className)}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export {
|
||
AltSayfa,
|
||
AltSayfaTetik,
|
||
AltSayfaKapat,
|
||
AltSayfaIcerik,
|
||
AltSayfaGovde,
|
||
AltSayfaSurukle,
|
||
AltSayfaBaslik,
|
||
AltSayfaAciklama,
|
||
};
|