feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
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>
This commit is contained in:
686
src/components/ui/alt-sayfa.tsx
Normal file
686
src/components/ui/alt-sayfa.tsx
Normal file
@@ -0,0 +1,686 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user