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:
bilalgursen
2026-10-07 17:12:53 +03:00
parent bc920f6111
commit 3c995402c6
38 changed files with 5474 additions and 697 deletions

View 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,
};