Files
kolaytercih/src/components/ui/alt-sayfa.tsx
bilalgursen 3c995402c6 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>
2026-10-07 17:12:53 +03:00

687 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
};