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:
@@ -10,7 +10,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Wrench, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
|
||||
@@ -470,6 +470,111 @@ export function PixelSira({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Piksel sahne (DESIGN.md §4.31): hero'daki dot-matrix Türkiye'nin dilini
|
||||
* küçük resimlere taşır. Harita gibi satır satır bir ızgaradan çizilir:
|
||||
* `#` doku pikseli, `-` silik piksel, `o` turuncu vurgu (hero'daki şehirler
|
||||
* gibi nabız atar), başka her karakter boş. Doku pikselleri imleç ısı
|
||||
* fırçasına katılır. Resim kartın söylediğini gösterir (süs değil); anlamı
|
||||
* yanındaki başlık taşır, bu yüzden aria-hidden.
|
||||
*/
|
||||
export function PixelSahne({
|
||||
izgara,
|
||||
ton = "doku",
|
||||
yaricap = 26,
|
||||
className,
|
||||
}: {
|
||||
izgara: readonly string[];
|
||||
/** "doku": hero haritasının silik mavisi. "dolgun": aynı doku koyu
|
||||
* tonda (0,64–1) — rakam gibi okunması gereken şekiller için. */
|
||||
ton?: "doku" | "dolgun";
|
||||
/** Isı fırçasının yarıçapı (viewBox birimi; hücre 10) */
|
||||
yaricap?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
usePixelHeat(svgRef, { radius: yaricap });
|
||||
const cols = Math.max(...izgara.map((satir) => satir.length));
|
||||
|
||||
const pixels = useMemo(() => {
|
||||
const opaklik = (x: number, y: number) =>
|
||||
ton === "dolgun" ? 0.4 + opacityFor(x, y) * 2 : opacityFor(x, y);
|
||||
const sonuc: ReactNode[] = [];
|
||||
let vurgu = 0;
|
||||
izgara.forEach((satir, y) => {
|
||||
for (let x = 0; x < satir.length; x++) {
|
||||
const c = satir[x];
|
||||
if (c === "o") {
|
||||
sonuc.push(
|
||||
pixelRect(x, y, {
|
||||
key: `${x}-${y}`,
|
||||
accent: true,
|
||||
accentDelay: (vurgu++ % 8) * 0.45,
|
||||
}),
|
||||
);
|
||||
} else if (c === "#" || c === "-") {
|
||||
const op =
|
||||
Math.round(opaklik(x, y) * (c === "-" ? 0.5 : 1) * 100) / 100;
|
||||
sonuc.push(
|
||||
<rect
|
||||
key={`${x}-${y}`}
|
||||
x={x * CELL + INSET}
|
||||
y={y * CELL + INSET}
|
||||
width={DOT}
|
||||
height={DOT}
|
||||
rx={2}
|
||||
data-px=""
|
||||
data-cx={x * CELL + CELL / 2}
|
||||
data-cy={y * CELL + CELL / 2}
|
||||
data-op={op}
|
||||
fill="currentColor"
|
||||
fillOpacity={op}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return sonuc;
|
||||
}, [izgara, ton]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${cols * CELL} ${izgara.length * CELL}`}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className={cn("h-auto w-full text-primary", className)}
|
||||
>
|
||||
{pixels}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 5×7 dot-matrix rakamlar. Yeni bir rakam gerekirse ızgarası buraya eklenir.
|
||||
const RAKAMLAR = {
|
||||
1: ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."],
|
||||
2: [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"],
|
||||
3: [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."],
|
||||
} as const;
|
||||
|
||||
/** Hero'nun piksel diliyle yazılmış iri rakam (ana sayfa adım numaraları). */
|
||||
export function PixelRakam({
|
||||
rakam,
|
||||
className,
|
||||
}: {
|
||||
rakam: keyof typeof RAKAMLAR;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<PixelSahne
|
||||
izgara={RAKAMLAR[rakam]}
|
||||
ton="dolgun"
|
||||
yaricap={22}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
|
||||
export function SectionEyebrow({
|
||||
children,
|
||||
|
||||
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,
|
||||
};
|
||||
696
src/components/ui/bildirim.tsx
Normal file
696
src/components/ui/bildirim.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
// Bildirim yığını (DESIGN.md §4.16). Kısa sonuç bildirimleri üst kenarda
|
||||
// üst üste birikir: yenisi yukarıdan iner, eskiler arkasına saklanıp altından
|
||||
// birer şerit gösterir; üzerine gelince (ya da dokununca) yığın listeye
|
||||
// açılır. Açıkken ve sekme gizliyken süre durur; sağa kaydırınca kapanır.
|
||||
// Aynı `id` ile ikinci çağrı yeni bildirim açmaz, olanı yerinde günceller
|
||||
// (ikon, metin ve boy birlikte dönüşür).
|
||||
//
|
||||
// Kaynak: Arc UI "Toast stack" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
|
||||
// - Yığın üstte durur (Arc altta): altta liste çekmecesinin çağrı bandı ve
|
||||
// telefonda klavye var; "Geri al" bildirimi onları örtmesin.
|
||||
// - Sağlayıcı yok; `toast` modül seviyesinde tek kuyruktur ve çağrı biçimi
|
||||
// eski `sonner` ile aynıdır (`toast.success(başlık, { description, action,
|
||||
// id, duration })`), kullanım yerleri yalnız import yolunu değiştirir.
|
||||
// - Aksiyon düğmesi üst çubuktaki koyu hap; yaylar sekmesiz (§6).
|
||||
//
|
||||
// İç düzen Arc'ınki gibi tek hizadadır: ikon, başlığın ilk satırı, aksiyon
|
||||
// hapı ve çarpı aynı 36 px'lik şeridin ortasına oturur (çarpı ve hap 36 px;
|
||||
// ikon kutusu 36 px; metin 8 px üst-alt payla 20 px satır). Tek satırlık
|
||||
// bildirimde hepsi kutunun tam ortasındadır, çok satırlıda ilk satırla
|
||||
// hizalı kalır. Bizdeki fark: açıklaması olan bildirimde aksiyon metnin
|
||||
// altına iner (Türkçe açıklamalar uzun; yanında durursa metni üç-dört satıra
|
||||
// sıkıştırır) — Arc bunu dar kutuda yapar.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
animate,
|
||||
motion,
|
||||
useIsPresent,
|
||||
useMotionValue,
|
||||
usePresence,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type HTMLMotionProps,
|
||||
type MotionProps,
|
||||
} from "motion/react";
|
||||
import { CircleCheck, Info, Loader2, OctagonX, TriangleAlert, X } from "lucide-react";
|
||||
import { EGRI, SURE, YAY, lastik } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tur = "duz" | "basari" | "bilgi" | "uyari" | "hata" | "yukleniyor";
|
||||
|
||||
export interface BildirimSecenekleri {
|
||||
/** Aynı kimlikle çağrı yeni bildirim açmaz, olanı günceller. */
|
||||
id?: string | number;
|
||||
description?: ReactNode;
|
||||
action?: { label: ReactNode; onClick: () => void };
|
||||
/** ms. Verilmezse türe göre; `Infinity` kapatılana kadar kalır. */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
type Kayit = {
|
||||
id: string;
|
||||
tur: Tur;
|
||||
baslik: ReactNode;
|
||||
aciklama?: ReactNode;
|
||||
aksiyon?: BildirimSecenekleri["action"];
|
||||
sure: number;
|
||||
sira: number;
|
||||
surum: number;
|
||||
};
|
||||
|
||||
/** Arkaya saklanan her bildirimin öndekinin altından görünen payı (px). */
|
||||
const PAY = 12;
|
||||
/** Yığın açıkken bildirimler arası boşluk. */
|
||||
const ARALIK = 10;
|
||||
/** Saklanan bildirim her adımda bu kadar küçülür. */
|
||||
const KUCULME = 0.05;
|
||||
/** Aynı anda görünen bildirim; fazlası arkada bekler. */
|
||||
const GORUNEN = 3;
|
||||
const KUYRUK_SINIRI = 8;
|
||||
const TABAN_SURE = 5000;
|
||||
/** Bu kadar sağa kaydırılan bildirim büyük ölçüde sönmüştür. */
|
||||
const KAYDIRMA_SONME = 280;
|
||||
|
||||
const TUR_ETIKETI: Record<Tur, string> = {
|
||||
duz: "Bildirim",
|
||||
basari: "Tamam",
|
||||
bilgi: "Bilgi",
|
||||
uyari: "Uyarı",
|
||||
hata: "Hata",
|
||||
yukleniyor: "Sürüyor",
|
||||
};
|
||||
|
||||
const sureBul = (tur: Tur) =>
|
||||
tur === "yukleniyor"
|
||||
? Infinity
|
||||
: tur === "uyari" || tur === "hata"
|
||||
? TABAN_SURE * 1.6
|
||||
: TABAN_SURE;
|
||||
|
||||
// ── Kuyruk (modül seviyesinde tek örnek) ────────────────────────────────
|
||||
|
||||
let kayitlar: Kayit[] = [];
|
||||
let sayac = 0;
|
||||
const dinleyiciler = new Set<() => void>();
|
||||
const BOS: Kayit[] = [];
|
||||
|
||||
function yayinla(sonraki: Kayit[]) {
|
||||
kayitlar = sonraki;
|
||||
dinleyiciler.forEach((d) => d());
|
||||
}
|
||||
|
||||
function goster(tur: Tur, baslik: ReactNode, secenekler: BildirimSecenekleri = {}): string {
|
||||
const id = secenekler.id != null ? String(secenekler.id) : null;
|
||||
const mevcut = id ? kayitlar.find((k) => k.id === id) : undefined;
|
||||
const ortak = {
|
||||
tur,
|
||||
baslik,
|
||||
aciklama: secenekler.description,
|
||||
aksiyon: secenekler.action,
|
||||
sure: secenekler.duration ?? sureBul(tur),
|
||||
};
|
||||
if (mevcut) {
|
||||
yayinla(
|
||||
kayitlar.map((k) =>
|
||||
k.id === mevcut.id ? { ...k, ...ortak, surum: k.surum + 1 } : k,
|
||||
),
|
||||
);
|
||||
return mevcut.id;
|
||||
}
|
||||
sayac += 1;
|
||||
const kayit: Kayit = { ...ortak, id: id ?? `bildirim-${sayac}`, sira: sayac, surum: 0 };
|
||||
yayinla([kayit, ...kayitlar].slice(0, KUYRUK_SINIRI));
|
||||
return kayit.id;
|
||||
}
|
||||
|
||||
function kapat(id?: string | number) {
|
||||
if (id == null) {
|
||||
if (kayitlar.length) yayinla([]);
|
||||
return;
|
||||
}
|
||||
const anahtar = String(id);
|
||||
if (kayitlar.some((k) => k.id === anahtar)) {
|
||||
yayinla(kayitlar.filter((k) => k.id !== anahtar));
|
||||
}
|
||||
}
|
||||
|
||||
/** Aksiyon kendi bildirimini güncellemediyse (ör. "Geri al") bildirim kapanır. */
|
||||
function aksiyonuCalistir(id: string) {
|
||||
const kayit = kayitlar.find((k) => k.id === id);
|
||||
if (!kayit?.aksiyon) return;
|
||||
kayit.aksiyon.onClick();
|
||||
if (kayitlar.find((k) => k.id === id)?.surum === kayit.surum) kapat(id);
|
||||
}
|
||||
|
||||
type Goster = (baslik: ReactNode, secenekler?: BildirimSecenekleri) => string;
|
||||
|
||||
export const toast: Goster & {
|
||||
success: Goster;
|
||||
info: Goster;
|
||||
warning: Goster;
|
||||
error: Goster;
|
||||
loading: Goster;
|
||||
dismiss: typeof kapat;
|
||||
} = Object.assign(
|
||||
((baslik, secenekler) => goster("duz", baslik, secenekler)) as Goster,
|
||||
{
|
||||
success: ((b, s) => goster("basari", b, s)) as Goster,
|
||||
info: ((b, s) => goster("bilgi", b, s)) as Goster,
|
||||
warning: ((b, s) => goster("uyari", b, s)) as Goster,
|
||||
error: ((b, s) => goster("hata", b, s)) as Goster,
|
||||
loading: ((b, s) => goster("yukleniyor", b, s)) as Goster,
|
||||
dismiss: kapat,
|
||||
},
|
||||
);
|
||||
|
||||
const abone = (dinleyici: () => void) => {
|
||||
dinleyiciler.add(dinleyici);
|
||||
return () => {
|
||||
dinleyiciler.delete(dinleyici);
|
||||
};
|
||||
};
|
||||
const gorunurlukAbone = (dinleyici: () => void) => {
|
||||
document.addEventListener("visibilitychange", dinleyici);
|
||||
return () => document.removeEventListener("visibilitychange", dinleyici);
|
||||
};
|
||||
|
||||
// ── Yerleşim ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Hedef = { y: number; olcek: number; boy: number; opaklik: number; icerik: number };
|
||||
|
||||
/**
|
||||
* Kapalıyken saklanan bildirimler öndekinin boyunu alır ki kenarları hizalı
|
||||
* dursun; açıkken her biri kendi boyunda, aralıkla alt alta dizilir. Liste,
|
||||
* aradaki boşlukları da kaplar: imleç bildirimden bildirime geçerken yığın
|
||||
* kapanmaz.
|
||||
*/
|
||||
function yigiYerlestir(liste: Kayit[], boylar: Record<string, number>, acik: boolean) {
|
||||
const on = liste.length ? (boylar[liste[0].id] ?? 0) : 0;
|
||||
const ustler = liste.reduce<number[]>(
|
||||
(toplam, k, i) => [
|
||||
...toplam,
|
||||
toplam[i] + (i < GORUNEN ? (boylar[k.id] ?? 0) + ARALIK : 0),
|
||||
],
|
||||
[0],
|
||||
);
|
||||
const hedefler = liste.map((k, i): Hedef => {
|
||||
const gorunur = i < GORUNEN;
|
||||
const derinlik = Math.min(i, GORUNEN);
|
||||
return acik
|
||||
? { y: ustler[i], olcek: 1, boy: boylar[k.id] ?? 0, opaklik: gorunur ? 1 : 0, icerik: 1 }
|
||||
: {
|
||||
y: PAY * derinlik,
|
||||
olcek: 1 - KUCULME * derinlik,
|
||||
boy: i === 0 ? (boylar[k.id] ?? 0) : on,
|
||||
opaklik: gorunur ? 1 : 0,
|
||||
icerik: i === 0 ? 1 : 0,
|
||||
};
|
||||
});
|
||||
const listeBoyu = !liste.length
|
||||
? 0
|
||||
: acik
|
||||
? ustler[liste.length] - ARALIK
|
||||
: on + PAY * (Math.min(liste.length, GORUNEN) - 1);
|
||||
return { hedefler, listeBoyu };
|
||||
}
|
||||
|
||||
// ── Tek bildirim ─────────────────────────────────────────────────────────
|
||||
|
||||
const solma = { duration: SURE.hizli, ease: EGRI.standart };
|
||||
const girisSolma = { duration: SURE.standart, ease: EGRI.giris };
|
||||
const yalnizOpaklik: MotionProps = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0, transition: solma },
|
||||
transition: solma,
|
||||
};
|
||||
const metinDegisimi: MotionProps = {
|
||||
initial: { opacity: 0, y: "0.3em", filter: "blur(4px)" },
|
||||
animate: { opacity: 1, y: "0em", filter: "blur(0px)" },
|
||||
exit: { opacity: 0, y: "-0.3em", filter: "blur(2px)", transition: solma },
|
||||
transition: { duration: SURE.standart, ease: EGRI.giris },
|
||||
};
|
||||
const ikonDegisimi: MotionProps = {
|
||||
initial: { opacity: 0, scale: 0.6 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.6, transition: solma },
|
||||
transition: { ...YAY.varsayilan, opacity: solma },
|
||||
};
|
||||
|
||||
/** Çıkan kopya sönerken ekran okuyucudan gizlenir; canlı bölge yalnız güncel metni okur. */
|
||||
function Degisen(props: HTMLMotionProps<"span">) {
|
||||
const burada = useIsPresent();
|
||||
return <motion.span {...props} aria-hidden={burada ? props["aria-hidden"] : true} />;
|
||||
}
|
||||
|
||||
function TurIkonu({ tur }: { tur: Tur }) {
|
||||
if (tur === "basari") return <CircleCheck className="size-5 text-emerald-600" aria-hidden />;
|
||||
if (tur === "bilgi") return <Info className="size-5 text-primary" aria-hidden />;
|
||||
if (tur === "uyari") return <TriangleAlert className="size-5 text-amber-600" aria-hidden />;
|
||||
if (tur === "hata") return <OctagonX className="size-5 text-red-600" aria-hidden />;
|
||||
// Sürüyor: sitenin ortak "bekle" ikonu (§4.20); tür değişince dönen halka durur.
|
||||
return (
|
||||
<Loader2 className="size-5 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
|
||||
);
|
||||
}
|
||||
|
||||
// Aksiyon hapı: üst çubuktaki koyu hap, çarpıyla aynı boyda (36 px).
|
||||
const AKSIYON_HAPI =
|
||||
"inline-flex h-9 shrink-0 cursor-pointer items-center rounded-full bg-slate-900 px-3.5 text-sm font-semibold whitespace-nowrap text-slate-50 transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]";
|
||||
|
||||
type Hareket = {
|
||||
isaretci: number;
|
||||
basX: number;
|
||||
basY: number;
|
||||
koken: number;
|
||||
etkin: boolean;
|
||||
ornekler: { t: number; x: number }[];
|
||||
};
|
||||
|
||||
function Bildirim({
|
||||
kayit,
|
||||
hedef,
|
||||
acik,
|
||||
onde,
|
||||
gizli,
|
||||
durdu,
|
||||
azalt,
|
||||
olcumBildir,
|
||||
surukleniyor,
|
||||
dokunuldu,
|
||||
}: {
|
||||
kayit: Kayit;
|
||||
hedef: Hedef;
|
||||
acik: boolean;
|
||||
onde: boolean;
|
||||
gizli: boolean;
|
||||
durdu: boolean;
|
||||
azalt: boolean;
|
||||
olcumBildir: (id: string, boy: number) => void;
|
||||
surukleniyor: (durum: boolean) => void;
|
||||
dokunuldu: () => void;
|
||||
}) {
|
||||
const { id } = kayit;
|
||||
const [burada, kaldirilabilir] = usePresence();
|
||||
const ogeRef = useRef<HTMLLIElement>(null);
|
||||
const icerikRef = useRef<HTMLDivElement>(null);
|
||||
// Yığın geometrisi motion değerlerinde: yığını, parmağı ya da süreyi
|
||||
// izlemek React'i kare başına yeniden çizdirmez.
|
||||
const y = useMotionValue(0);
|
||||
const olcek = useMotionValue(1);
|
||||
const boy = useMotionValue(0);
|
||||
const opaklik = useMotionValue(0);
|
||||
const icerikOpakligi = useMotionValue(1);
|
||||
const x = useMotionValue(0);
|
||||
const kaydirmaSonmesi = useTransform(x, (v) =>
|
||||
v > 0 ? 1 - Math.min(v / KAYDIRMA_SONME, 1) * 0.75 : 1,
|
||||
);
|
||||
/** Son hedefin uygulandığı açık/kapalı hâl; giriş bitene kadar null. */
|
||||
const uygulanan = useRef<boolean | null>(null);
|
||||
const kaydirildi = useRef(false);
|
||||
const kalan = useRef(kayit.sure);
|
||||
const hareket = useRef<Hareket | null>(null);
|
||||
const tiklamayiYut = useRef(false);
|
||||
const isaretciTuru = useRef("mouse");
|
||||
|
||||
// İlk boyamadan önce ölç: bildirim tam kendi boyu kadar yukarıdan girer.
|
||||
useLayoutEffect(() => {
|
||||
const dugum = icerikRef.current;
|
||||
if (!dugum) return;
|
||||
const olc = () => olcumBildir(id, dugum.offsetHeight + 2);
|
||||
olc();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const gozlemci = new ResizeObserver(olc);
|
||||
gozlemci.observe(dugum);
|
||||
return () => gozlemci.disconnect();
|
||||
}, [id, olcumBildir]);
|
||||
|
||||
// Yığındaki her değişiklik süren yayı bulunduğu yerden yeniden hedefler;
|
||||
// art arda gelen bildirimler ve hover değişimleri sıraya girmez.
|
||||
useLayoutEffect(() => {
|
||||
if (!burada || hedef.boy <= 0) return;
|
||||
const giriyor = uygulanan.current === null;
|
||||
uygulanan.current = acik;
|
||||
if (azalt) {
|
||||
y.jump(hedef.y);
|
||||
olcek.jump(hedef.olcek);
|
||||
boy.jump(hedef.boy);
|
||||
animate(opaklik, hedef.opaklik, solma);
|
||||
animate(icerikOpakligi, hedef.icerik, solma);
|
||||
return;
|
||||
}
|
||||
if (giriyor) {
|
||||
y.jump(hedef.y - hedef.boy);
|
||||
olcek.jump(hedef.olcek);
|
||||
boy.jump(hedef.boy);
|
||||
icerikOpakligi.jump(hedef.icerik);
|
||||
opaklik.jump(0);
|
||||
}
|
||||
animate(y, hedef.y, YAY.akici);
|
||||
animate(olcek, hedef.olcek, YAY.akici);
|
||||
if (!giriyor) animate(boy, hedef.boy, YAY.akici);
|
||||
animate(opaklik, hedef.opaklik, giriyor ? girisSolma : solma);
|
||||
animate(icerikOpakligi, hedef.icerik, solma);
|
||||
}, [acik, azalt, boy, burada, hedef.boy, hedef.icerik, hedef.olcek, hedef.opaklik, hedef.y, icerikOpakligi, olcek, opaklik, y]);
|
||||
|
||||
// Güncelleme saati baştan başlatır; duraklatma kalan süreyi korur.
|
||||
useEffect(() => {
|
||||
kalan.current = kayit.sure;
|
||||
}, [kayit.surum, kayit.sure]);
|
||||
useEffect(() => {
|
||||
if (!burada || durdu || !Number.isFinite(kalan.current) || kayit.sure <= 0) return;
|
||||
const basladi = performance.now();
|
||||
const zamanlayici = window.setTimeout(() => kapat(id), Math.max(kalan.current, 0));
|
||||
return () => {
|
||||
window.clearTimeout(zamanlayici);
|
||||
kalan.current -= performance.now() - basladi;
|
||||
};
|
||||
}, [burada, durdu, id, kayit.surum, kayit.sure]);
|
||||
|
||||
// Çıkış: geldiği yoldan (yukarı) çekilir; fırlatılan uçmaya devam eder.
|
||||
useEffect(() => {
|
||||
if (burada) return;
|
||||
ogeRef.current?.setAttribute("inert", "");
|
||||
const bitti = () => kaldirilabilir?.();
|
||||
if (azalt) {
|
||||
animate(opaklik, 0, solma).then(bitti);
|
||||
return;
|
||||
}
|
||||
if (!kaydirildi.current) {
|
||||
animate(y, y.get() - 10, YAY.akici);
|
||||
animate(olcek, olcek.get() * 0.96, YAY.akici);
|
||||
}
|
||||
animate(opaklik, 0, {
|
||||
duration: kaydirildi.current ? 0.22 : SURE.cikis,
|
||||
ease: EGRI.standart,
|
||||
}).then(bitti);
|
||||
}, [azalt, burada, kaldirilabilir, olcek, opaklik, y]);
|
||||
|
||||
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
isaretciTuru.current = e.pointerType;
|
||||
tiklamayiYut.current = false;
|
||||
if (e.button !== 0 || !burada || (e.target as Element).closest("button, a")) return;
|
||||
// Hâlâ yerine dönen bildirimi tam bulunduğu yerden yakala.
|
||||
x.stop();
|
||||
hareket.current = {
|
||||
isaretci: e.pointerId,
|
||||
basX: e.clientX,
|
||||
basY: e.clientY,
|
||||
koken: x.get(),
|
||||
etkin: false,
|
||||
ornekler: [],
|
||||
};
|
||||
}
|
||||
|
||||
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
const h = hareket.current;
|
||||
if (!h || h.isaretci !== e.pointerId) return;
|
||||
if (!h.etkin) {
|
||||
const dx = e.clientX - h.basX;
|
||||
const dy = e.clientY - h.basY;
|
||||
if (Math.hypot(dx, dy) < 6) return;
|
||||
if (Math.abs(dy) > Math.abs(dx)) {
|
||||
hareket.current = null;
|
||||
return;
|
||||
}
|
||||
// Buradan ölç: eşiği geçerken bildirim sıçramaz.
|
||||
h.etkin = true;
|
||||
h.basX = e.clientX;
|
||||
tiklamayiYut.current = true;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
surukleniyor(true);
|
||||
}
|
||||
const ham = h.koken + e.clientX - h.basX;
|
||||
// Sağa parmağı birebir izler; sol yanlış yön, lastik gibi direnir.
|
||||
x.set(ham >= 0 ? ham : -lastik(-ham, e.currentTarget.offsetWidth));
|
||||
h.ornekler.push({ t: e.timeStamp, x: ham });
|
||||
while (h.ornekler.length > 2 && e.timeStamp - h.ornekler[0].t > 100) h.ornekler.shift();
|
||||
}
|
||||
|
||||
function birakildi(e: ReactPointerEvent<HTMLDivElement>, iptal: boolean) {
|
||||
const h = hareket.current;
|
||||
if (!h || h.isaretci !== e.pointerId) return;
|
||||
hareket.current = null;
|
||||
if (!h.etkin) return;
|
||||
surukleniyor(false);
|
||||
const ilk = h.ornekler[0];
|
||||
const son = h.ornekler[h.ornekler.length - 1];
|
||||
const hiz =
|
||||
iptal || !ilk || !son || son.t === ilk.t || e.timeStamp - son.t > 60
|
||||
? 0
|
||||
: ((son.x - ilk.x) / (son.t - ilk.t)) * 1000;
|
||||
const genislik = e.currentTarget.offsetWidth;
|
||||
const konum = x.get();
|
||||
// Bırakma hızının götüreceği yer (hızlı kaydırma yavaşlaması).
|
||||
const varis = konum + (hiz / 1000) * 99;
|
||||
if (!iptal && konum > 0 && hiz > -200 && varis > genislik * 0.4) {
|
||||
kaydirildi.current = true;
|
||||
if (!azalt) {
|
||||
animate(x, genislik + 40, {
|
||||
type: "spring",
|
||||
visualDuration: 0.32,
|
||||
bounce: 0,
|
||||
velocity: Math.max(hiz, 0),
|
||||
});
|
||||
}
|
||||
kapat(id);
|
||||
return;
|
||||
}
|
||||
if (azalt) x.jump(0);
|
||||
else animate(x, 0, { ...YAY.birakma, velocity: konum < 0 ? hiz * 0.3 : hiz });
|
||||
}
|
||||
|
||||
function kartaTiklandi(e: MouseEvent<HTMLDivElement>) {
|
||||
if (tiklamayiYut.current) {
|
||||
tiklamayiYut.current = false;
|
||||
return;
|
||||
}
|
||||
// Dokunmatikte hover yok: karta dokunmak yığını açar ya da kapatır.
|
||||
if (isaretciTuru.current !== "mouse" && !(e.target as Element).closest("button, a")) {
|
||||
dokunuldu();
|
||||
}
|
||||
}
|
||||
|
||||
function tusaBasildi(e: KeyboardEvent<HTMLLIElement>) {
|
||||
if (e.key !== "Escape") return;
|
||||
e.stopPropagation();
|
||||
kapat(id);
|
||||
}
|
||||
|
||||
const degisim = azalt ? yalnizOpaklik : metinDegisimi;
|
||||
// Açıklama varsa aksiyon metnin altında, yoksa başlığın yanında (Arc düzeni).
|
||||
const aksiyonAltta = Boolean(kayit.aciklama);
|
||||
|
||||
return (
|
||||
<motion.li
|
||||
ref={ogeRef}
|
||||
// Saklanan bildirim alt kenarından küçülür: öndekinin altından tam pay kadar görünür.
|
||||
className="absolute inset-x-0 top-0 origin-bottom"
|
||||
style={{ y, scale: olcek, height: boy, opacity: opaklik, zIndex: kayit.sira }}
|
||||
inert={gizli}
|
||||
onKeyDown={tusaBasildi}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute inset-0 touch-pan-y overflow-hidden rounded-2xl border border-slate-200 shadow-lg shadow-slate-900/5 transition-colors duration-200",
|
||||
onde || acik ? "bg-card" : "bg-slate-50",
|
||||
)}
|
||||
style={{ x, opacity: kaydirmaSonmesi }}
|
||||
onPointerDown={basildi}
|
||||
onPointerMove={oynadi}
|
||||
onPointerUp={(e) => birakildi(e, false)}
|
||||
onPointerCancel={(e) => birakildi(e, true)}
|
||||
onClick={kartaTiklandi}
|
||||
>
|
||||
<motion.div
|
||||
ref={icerikRef}
|
||||
// Üst-alt 6 px + 36 px'lik ilk satır şeridi: tek satırlık bildirim 48 px.
|
||||
className="flex items-start gap-3 py-1.5 pr-1.5 pl-4"
|
||||
style={{ opacity: icerikOpakligi }}
|
||||
>
|
||||
{kayit.tur === "duz" ? null : (
|
||||
<span className="relative grid h-9 w-5 shrink-0 place-items-center" aria-hidden>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<Degisen
|
||||
key={kayit.tur}
|
||||
className="col-start-1 row-start-1 grid place-items-center"
|
||||
{...(azalt ? yalnizOpaklik : ikonDegisimi)}
|
||||
>
|
||||
<TurIkonu tur={kayit.tur} />
|
||||
</Degisen>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
)}
|
||||
<div className="relative min-w-0 flex-1 py-2 text-sm leading-5">
|
||||
<span className="sr-only">{TUR_ETIKETI[kayit.tur]}: </span>
|
||||
<span className="relative block font-semibold text-slate-900">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<Degisen
|
||||
key={typeof kayit.baslik === "string" ? kayit.baslik : kayit.surum}
|
||||
className="block"
|
||||
{...degisim}
|
||||
>
|
||||
{kayit.baslik}
|
||||
</Degisen>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
{kayit.aciklama ? (
|
||||
<span className="mt-0.5 block text-pretty text-slate-600">{kayit.aciklama}</span>
|
||||
) : null}
|
||||
{kayit.aksiyon && aksiyonAltta ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aksiyonuCalistir(id)}
|
||||
className={cn(AKSIYON_HAPI, "mt-2.5")}
|
||||
>
|
||||
{kayit.aksiyon.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Sağ grup: aksiyon ile çarpı yan yana, ilk satırla aynı hizada */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{kayit.aksiyon && !aksiyonAltta ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aksiyonuCalistir(id)}
|
||||
className={AKSIYON_HAPI}
|
||||
>
|
||||
{kayit.aksiyon.label}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => kapat(id)}
|
||||
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Bildirimi kapat</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Görünüm alanı ────────────────────────────────────────────────────────
|
||||
|
||||
/** Layout'ta bir kez durur; bildirimler `toast(...)` ile her yerden gösterilir. */
|
||||
export function BildirimYigini() {
|
||||
const liste = useSyncExternalStore(abone, () => kayitlar, () => BOS);
|
||||
const azalt = !!useReducedMotion();
|
||||
const sayfaGizli = useSyncExternalStore(
|
||||
gorunurlukAbone,
|
||||
() => document.visibilityState === "hidden",
|
||||
() => false,
|
||||
);
|
||||
const [uzerinde, setUzerinde] = useState(false);
|
||||
const [odakta, setOdakta] = useState(false);
|
||||
const [dokunulmus, setDokunulmus] = useState(false);
|
||||
const [surukleme, setSurukleme] = useState(false);
|
||||
const [boylar, setBoylar] = useState<Record<string, number>>({});
|
||||
const bolgeRef = useRef<HTMLElement>(null);
|
||||
|
||||
if (dokunulmus && liste.length === 0) setDokunulmus(false);
|
||||
const acik = liste.length > 1 && (uzerinde || odakta || dokunulmus);
|
||||
// Tek bildirimde de üzerine gelmek süreyi durdurur (okumaya vakit kalsın).
|
||||
const durdu = uzerinde || odakta || dokunulmus || surukleme || sayfaGizli;
|
||||
|
||||
const olcumBildir = useCallback(
|
||||
(id: string, boy: number) =>
|
||||
setBoylar((onceki) => (onceki[id] === boy ? onceki : { ...onceki, [id]: boy })),
|
||||
[],
|
||||
);
|
||||
const buda = useCallback(
|
||||
() =>
|
||||
setBoylar((onceki) =>
|
||||
Object.fromEntries(
|
||||
kayitlar.filter((k) => k.id in onceki).map((k) => [k.id, onceki[k.id]]),
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
const dokunuldu = useCallback(() => setDokunulmus((a) => !a), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dokunulmus) return;
|
||||
const disari = (e: PointerEvent) => {
|
||||
if (!bolgeRef.current?.contains(e.target as Node)) setDokunulmus(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", disari);
|
||||
return () => document.removeEventListener("pointerdown", disari);
|
||||
}, [dokunulmus]);
|
||||
|
||||
const { hedefler, listeBoyu } = yigiYerlestir(liste, boylar, acik);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={bolgeRef}
|
||||
// Alt sayfa (alt-sayfa.tsx) bu işaretin içindeki tıklamayı "dışarı
|
||||
// tıklama" saymaz: "Geri al"a basmak çekmeceyi kapatmaz.
|
||||
data-bildirim-yigini=""
|
||||
aria-label="Bildirimler"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed inset-x-0 top-4 z-[70] mx-auto w-[min(23rem,calc(100%-2rem))] sm:top-6 print:hidden"
|
||||
onFocus={(e) => {
|
||||
// Yalnız klavye odağı yığını açar; fareyle düğmeye basmak açık tutmaz.
|
||||
try {
|
||||
if (e.target.matches(":focus-visible")) setOdakta(true);
|
||||
} catch {
|
||||
setOdakta(true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) {
|
||||
setOdakta(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ol
|
||||
className="pointer-events-auto relative"
|
||||
style={{ height: listeBoyu }}
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "mouse") setUzerinde(true);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "mouse") setUzerinde(false);
|
||||
}}
|
||||
>
|
||||
<AnimatePresence onExitComplete={buda}>
|
||||
{liste.map((kayit, i) => (
|
||||
<Bildirim
|
||||
key={kayit.id}
|
||||
kayit={kayit}
|
||||
hedef={hedefler[i]}
|
||||
acik={acik}
|
||||
onde={i === 0}
|
||||
gizli={i >= GORUNEN}
|
||||
durdu={durdu}
|
||||
azalt={azalt}
|
||||
olcumBildir={olcumBildir}
|
||||
surukleniyor={setSurukleme}
|
||||
dokunuldu={dokunuldu}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
177
src/components/ui/bolme-tutamaci.tsx
Normal file
177
src/components/ui/bolme-tutamaci.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
// Bölme tutamacı (DESIGN.md §4.25): yan yana iki alanın arasındaki çizgiyi
|
||||
// tutup sürükleyince alanlar yer değiştirir. Tutamaç işaretçiyi birebir
|
||||
// izler, sınırın ötesinde lastik gibi direnir ve bırakınca sınıra döner.
|
||||
// Odaklanabilir bir ayırıcıdır: ok tuşları boyutlar (Shift büyük adım),
|
||||
// Home / End sınırlara gider, çift tık varsayılana döndürür.
|
||||
//
|
||||
// Kaynak: Arc UI "Resizable panels" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
|
||||
// - Arc bölmeleri sabit boylu, kendi içinde kayan bir kap içinde yönetir.
|
||||
// /listem'de sayfa kayar ve sohbet sütunu yapışkandır; bu yüzden yalnız
|
||||
// tutamaç alındı — genişliği çağıran tutar (ızgaranın CSS değişkeni).
|
||||
// - Bölmeyi tamamen kapatma (collapse) yok: liste de sohbet de hep görünür.
|
||||
// - Dinlenirken de silik bir tutamaç görünür (fark edilsin); hover tek turuncu (§2.1).
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { animate, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
|
||||
import { YAY } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Sınırın ötesine en çok bu kadar çekilebilir (px). */
|
||||
const ESNEME = 44;
|
||||
const ADIM = 16;
|
||||
const BUYUK_ADIM = 64;
|
||||
|
||||
const sinirla = (v: number, alt: number, ust: number) => Math.min(ust, Math.max(alt, v));
|
||||
const direnc = (tasma: number) => (tasma * ESNEME * 0.55) / (ESNEME + 0.55 * tasma);
|
||||
const bant = (v: number, alt: number, ust: number) =>
|
||||
v < alt ? alt - direnc(alt - v) : v > ust ? ust + direnc(v - ust) : v;
|
||||
|
||||
export function BolmeTutamaci({
|
||||
etiket,
|
||||
kontrol,
|
||||
yon = 1,
|
||||
oku,
|
||||
yaz,
|
||||
sinirlar,
|
||||
onSifirla,
|
||||
className,
|
||||
}: {
|
||||
/** Ayırıcının boyutlandırdığı alanın adı, ör. "Danışman sohbeti". */
|
||||
etiket: string;
|
||||
/** Boyutlanan alanın `id`'si (`aria-controls`). */
|
||||
kontrol: string;
|
||||
/** `1`: sağa çekmek büyütür; `-1`: sola çekmek büyütür (alan tutamacın sağındaysa). */
|
||||
yon?: 1 | -1;
|
||||
/** Alanın şu anki genişliği (px). */
|
||||
oku: () => number;
|
||||
/** Yeni genişlik (px). Sürükleme boyunca kare başına çağrılır. */
|
||||
yaz: (px: number) => void;
|
||||
/** O anki ekran için en dar / en geniş değer. */
|
||||
sinirlar: () => { enAz: number; enCok: number };
|
||||
/** Çift tık: çağıran varsayılan genişliğe döner. */
|
||||
onSifirla?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const azalt = useReducedMotion();
|
||||
const [etkin, setEtkin] = useState(false);
|
||||
const [deger, setDeger] = useState<{ simdi: number; enAz: number; enCok: number } | null>(
|
||||
null,
|
||||
);
|
||||
const surukleme = useRef<{ isaretci: number; basX: number; koken: number; oynadi: boolean } | null>(
|
||||
null,
|
||||
);
|
||||
const donus = useRef<AnimationPlaybackControls | null>(null);
|
||||
|
||||
const bildir = (px: number) => {
|
||||
const { enAz, enCok } = sinirlar();
|
||||
setDeger({ simdi: Math.round(px), enAz: Math.round(enAz), enCok: Math.round(enCok) });
|
||||
};
|
||||
|
||||
/** Genişliği bulunduğu yerden hedefe yayla götürür (klavye, sınıra dönüş). */
|
||||
function git(hedef: number, gecis: typeof YAY.akici | typeof YAY.birakma = YAY.akici) {
|
||||
donus.current?.stop();
|
||||
bildir(hedef);
|
||||
const simdiki = oku();
|
||||
if (azalt || Math.abs(simdiki - hedef) < 0.5) {
|
||||
yaz(hedef);
|
||||
return;
|
||||
}
|
||||
donus.current = animate(simdiki, hedef, { ...gecis, onUpdate: yaz });
|
||||
}
|
||||
|
||||
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (surukleme.current || (e.pointerType === "mouse" && e.button !== 0)) return;
|
||||
donus.current?.stop();
|
||||
// Metin seçimi başlamasın; işaretçi hızlı çıksa da tutamaçta kalır.
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.focus({ preventScroll: true });
|
||||
surukleme.current = { isaretci: e.pointerId, basX: e.clientX, koken: oku(), oynadi: false };
|
||||
}
|
||||
|
||||
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
const s = surukleme.current;
|
||||
if (!s || s.isaretci !== e.pointerId) return;
|
||||
const dx = (e.clientX - s.basX) * yon;
|
||||
if (!s.oynadi) {
|
||||
if (Math.abs(dx) < 2) return;
|
||||
s.oynadi = true;
|
||||
setEtkin(true);
|
||||
}
|
||||
const { enAz, enCok } = sinirlar();
|
||||
yaz(bant(s.koken + dx, enAz, enCok));
|
||||
}
|
||||
|
||||
function bitti(e: ReactPointerEvent<HTMLDivElement>, iptal: boolean) {
|
||||
const s = surukleme.current;
|
||||
if (!s || s.isaretci !== e.pointerId) return;
|
||||
surukleme.current = null;
|
||||
setEtkin(false);
|
||||
if (!s.oynadi) return;
|
||||
const { enAz, enCok } = sinirlar();
|
||||
// Bırakılan yerde kalır; sınırın ötesindeyse sınıra, iptalse başladığı yere döner.
|
||||
git(iptal ? s.koken : sinirla(oku(), enAz, enCok), YAY.birakma);
|
||||
}
|
||||
|
||||
function tus(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
const { enAz, enCok } = sinirlar();
|
||||
const adim = (e.shiftKey ? BUYUK_ADIM : ADIM) * yon;
|
||||
const simdiki = oku();
|
||||
const hedef =
|
||||
e.key === "Home"
|
||||
? enAz
|
||||
: e.key === "End"
|
||||
? enCok
|
||||
: sinirla(simdiki + (e.key === "ArrowRight" ? adim : -adim), enAz, enCok);
|
||||
git(hedef);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-controls={kontrol}
|
||||
aria-label={`${etiket} genişliği`}
|
||||
aria-valuenow={deger?.simdi}
|
||||
aria-valuemin={deger?.enAz}
|
||||
aria-valuemax={deger?.enCok}
|
||||
aria-valuetext={deger ? `${deger.simdi} piksel` : undefined}
|
||||
title="Sürükleyerek genişliği değiştir · çift tık: varsayılan"
|
||||
data-etkin={etkin || undefined}
|
||||
onPointerDown={basildi}
|
||||
onPointerMove={oynadi}
|
||||
onPointerUp={(e) => bitti(e, false)}
|
||||
onPointerCancel={(e) => bitti(e, true)}
|
||||
onKeyDown={tus}
|
||||
onFocus={() => bildir(oku())}
|
||||
onDoubleClick={() => {
|
||||
donus.current?.stop();
|
||||
onSifirla?.();
|
||||
}}
|
||||
className={cn(
|
||||
// 1 px çizgi + 24 px tutma alanı; çizgi yalnız hover / odak / sürüklemede görünür.
|
||||
"group/bolme z-10 w-6 cursor-col-resize touch-none outline-none select-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-2 left-1/2 w-px -translate-x-1/2 rounded-full bg-orange-500 opacity-0 transition-opacity duration-150 group-hover/bolme:opacity-40 group-focus-visible/bolme:opacity-40 group-data-[etkin]/bolme:opacity-70"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-1/2 left-1/2 h-10 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-300 transition-[background-color,height] duration-200 group-hover/bolme:h-14 group-hover/bolme:bg-orange-500 group-focus-visible/bolme:h-14 group-focus-visible/bolme:bg-orange-500 group-data-[etkin]/bolme:h-14 group-data-[etkin]/bolme:bg-orange-500 motion-reduce:transition-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/ui/cip-grubu.tsx
Normal file
139
src/components/ui/cip-grubu.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
// Seçim çipi ve çip grubu (DESIGN.md §4.24): çok seçimli hap seçimlerinde
|
||||
// (sihirbazın alan / şehir / öncelik çipleri) seçilen çip yalnız renk
|
||||
// değiştirmez — başında bir tik kendini çizerek büyür, yazı yana kayar,
|
||||
// çipin kenarı yayla izler ve komşular yeni yerlerine süzülür. Seçili hâl
|
||||
// renk + tik'tir; renk tek başına anlam taşımaz (§2.2).
|
||||
//
|
||||
// Kaynak: Arc UI "Chip group" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
|
||||
// - Arc komşuları layout projeksiyonuyla kaydırır; biz tik yuvasının
|
||||
// genişliğini doğrudan yayla açıyoruz, komşular sayfa akışıyla kayar. Çipler
|
||||
// kayan alt sayfanın ve yüksekliği ölçülen sihirbaz panelinin içinde
|
||||
// yaşıyor: projeksiyon orada erken ölçümle satırları kaydırıyordu.
|
||||
// - "+N daha" katlaması alınmadı (şehirlerin tamamı haritayla birlikte görünür).
|
||||
// - Sınıf dizisi sitenin çipi (§4.17): 44 px, seçili mavi dolu, hover tek turuncu.
|
||||
// Tek seçimde kayan vurgu isteniyorsa `SecimHaplari` (§4.22) kullanılır.
|
||||
|
||||
import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { YAY } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Tik'in yazıdan aldığı yer: 14 px işaret + ardındaki boşluk. */
|
||||
const YUVA = 18;
|
||||
|
||||
export function SecimCipi({
|
||||
secili,
|
||||
onClick,
|
||||
children,
|
||||
kucuk = false,
|
||||
ton = "notr",
|
||||
className,
|
||||
}: {
|
||||
secili: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
/** Çok sayıda çip (iller): 40 px. */
|
||||
kucuk?: boolean;
|
||||
/** `bilgi`: pasif hâli bilgi mavisi tonunda (özel kontenjan durumu); seçili hâl aynı. */
|
||||
ton?: "notr" | "bilgi";
|
||||
className?: string;
|
||||
}) {
|
||||
const azalt = useReducedMotion();
|
||||
const gecis = azalt ? { duration: 0 } : YAY.donusum;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-cip=""
|
||||
onClick={onClick}
|
||||
aria-pressed={secili}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center rounded-full border text-sm font-semibold outline-none transition-[background-color,border-color,color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
|
||||
kucuk ? "h-10 px-3.5" : "h-11 px-4",
|
||||
secili
|
||||
? "border-primary bg-primary text-white"
|
||||
: ton === "bilgi"
|
||||
? "border-primary/30 bg-primary/10 text-primary hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"
|
||||
: "border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Tik yuvası: genişliği yayla açılır, yazı o kadar yana kayar; tik
|
||||
açılan aralıkta kendini çizer, seçim kalkınca aynı yoldan geri gider. */}
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="flex h-3.5 shrink-0 items-center overflow-hidden"
|
||||
initial={false}
|
||||
animate={{ width: secili ? YUVA : 0 }}
|
||||
transition={gecis}
|
||||
>
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<motion.path
|
||||
d="M4 12.5 9.5 18 20 6.5"
|
||||
initial={false}
|
||||
animate={{ pathLength: secili ? 1 : 0, opacity: secili ? 1 : 0 }}
|
||||
transition={gecis}
|
||||
/>
|
||||
</svg>
|
||||
</motion.span>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Çipleri saran grup: ekran okuyucuya adını söyler, ok tuşlarıyla çipten çipe
|
||||
* geçilir (Home / End uçlara gider). Yerleşim varsayılanı `flex flex-wrap gap-2`;
|
||||
* ızgara isteyen yer `className` ile değiştirir.
|
||||
*/
|
||||
export function CipGrubu({
|
||||
etiket,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: Omit<ComponentProps<"div">, "aria-label" | "role"> & {
|
||||
/** Grubun erişilebilir adı, ör. "İlgi alanları". */
|
||||
etiket: string;
|
||||
}) {
|
||||
function tus(e: KeyboardEvent<HTMLDivElement>) {
|
||||
const cipler = Array.from(
|
||||
e.currentTarget.querySelectorAll<HTMLButtonElement>("button[data-cip]"),
|
||||
);
|
||||
const konum = cipler.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (konum < 0) return;
|
||||
const son = cipler.length - 1;
|
||||
const hedefler: Record<string, number> = {
|
||||
ArrowRight: konum === son ? 0 : konum + 1,
|
||||
ArrowDown: konum === son ? 0 : konum + 1,
|
||||
ArrowLeft: konum === 0 ? son : konum - 1,
|
||||
ArrowUp: konum === 0 ? son : konum - 1,
|
||||
Home: 0,
|
||||
End: son,
|
||||
};
|
||||
if (!(e.key in hedefler)) return;
|
||||
e.preventDefault();
|
||||
cipler[hedefler[e.key]].focus();
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={etiket}
|
||||
className={cn("flex flex-wrap gap-2", className)}
|
||||
onKeyDown={tus}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHandle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Handle>) {
|
||||
return (
|
||||
<DrawerPrimitive.Handle
|
||||
data-slot="drawer-handle"
|
||||
className={cn(
|
||||
"mx-auto mt-4 hidden h-1.5 w-25 shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
showSwipeHandle = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
|
||||
showSwipeHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showSwipeHandle ? <DrawerHandle /> : null}
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
615
src/components/ui/kart-destesi.tsx
Normal file
615
src/components/ui/kart-destesi.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
// Kart destesi (DESIGN.md §4.27): kartlar tek tek gözden geçirilir. Üstteki
|
||||
// kart sağa ya da sola sürüklenip fırlatılır; aynı karar düğmelerle ve ok
|
||||
// tuşlarıyla da verilir, son karar geri alınır. Arkadaki kartlar üstteki
|
||||
// uzaklaştıkça yerinden kalkar. Bırakılan kart parmağın hızını taşır.
|
||||
//
|
||||
// Kaynak: Arc UI "Card stack" (uiarc.dev, 7 Eki 2026). Bizdeki farklar: sınıf
|
||||
// dizisi sitenin kartı ve hap düğmeleri (§4.1, §4.4); onay düğmesi mavi
|
||||
// (turuncu yalnız funnel CTA'sı, §2); yaylar sekmesiz, bırakılan kartın yerine
|
||||
// dönüşünde tek hafif sekme (§6).
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
useTransform,
|
||||
type MotionValue,
|
||||
} from "motion/react";
|
||||
import { Check, RotateCcw, X } from "lucide-react";
|
||||
import { EGRI, SURE, YAY, lastik, lastikTersi } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type KartKarari = "sol" | "sag";
|
||||
|
||||
type Nokta = { x: number; y: number };
|
||||
type Cikis = Nokta & { egim: number };
|
||||
type Donus = Cikis & { gecikme: number };
|
||||
|
||||
/** Destede çizilen kart sayısı; sonuncusu bir adım geride görünmeden bekler. */
|
||||
const GORUNEN = 4;
|
||||
/** Arkadaki her kartın öndekinin altından görünen payı ve küçülmesi. */
|
||||
const PAY = 12;
|
||||
const KUCULME = 0.05;
|
||||
/** Üst kart sürüklenirken arkadakilerin ne kadar yükseldiği (deste adımı). */
|
||||
const KALKIS = 0.4;
|
||||
const KALKIS_MESAFESI = 160;
|
||||
/** Yatay her piksel için dönüş (derece). */
|
||||
const DONME = 0.065;
|
||||
/** Bundan hızlı bırakış (px/sn), kart az oynamış olsa da fırlatır. */
|
||||
const FISKE = 500;
|
||||
/** Dikey yol karar değildir: küçük serbest payın ötesinde lastik gibi direnir. */
|
||||
const SERBEST_Y = 48;
|
||||
const ESNEME_Y = 140;
|
||||
|
||||
const bosAbone = () => () => {};
|
||||
const direnY = (ham: number) => {
|
||||
const mesafe = Math.abs(ham);
|
||||
return mesafe <= SERBEST_Y
|
||||
? ham
|
||||
: Math.sign(ham) * (SERBEST_Y + lastik(mesafe - SERBEST_Y, ESNEME_Y));
|
||||
};
|
||||
const direnYTersi = (gorunen: number) => {
|
||||
const mesafe = Math.abs(gorunen);
|
||||
return mesafe <= SERBEST_Y
|
||||
? gorunen
|
||||
: Math.sign(gorunen) * (SERBEST_Y + lastikTersi(mesafe - SERBEST_Y, ESNEME_Y));
|
||||
};
|
||||
const birim = (v: number) => Math.min(1, Math.max(0, v));
|
||||
const FIRLATMA = { type: "spring", visualDuration: 0.5, bounce: 0 } as const;
|
||||
const UCARKEN_SOLMA = { duration: 0.22, delay: 0.12, ease: EGRI.standart } as const;
|
||||
const SADE_SOLMA = { duration: 0.15, ease: "linear" } as const;
|
||||
|
||||
function hizBul(ornekler: { t: number; x: number; y: number }[], simdi: number): Nokta {
|
||||
const yakin = ornekler.filter((o) => simdi - o.t <= 80);
|
||||
const ilk = yakin[0];
|
||||
const son = yakin[yakin.length - 1];
|
||||
if (!ilk || !son || ilk === son || simdi - son.t > 60) return { x: 0, y: 0 };
|
||||
const sn = (son.t - ilk.t) / 1000;
|
||||
return { x: (son.x - ilk.x) / sn, y: (son.y - ilk.y) / sn };
|
||||
}
|
||||
|
||||
function DesteKarti({
|
||||
id,
|
||||
derinlik,
|
||||
karar,
|
||||
donus,
|
||||
etkin,
|
||||
zIndex,
|
||||
ad,
|
||||
etiketler,
|
||||
kalkis,
|
||||
azalt,
|
||||
firlatmaHizi,
|
||||
onBirak,
|
||||
onFirlatildi,
|
||||
onGitti,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
derinlik: number;
|
||||
karar: KartKarari | null;
|
||||
donus?: Donus;
|
||||
etkin: boolean;
|
||||
zIndex: number;
|
||||
ad: string;
|
||||
etiketler: { sol: string; sag: string };
|
||||
kalkis: MotionValue<number>;
|
||||
azalt: boolean;
|
||||
firlatmaHizi: (id: string) => Nokta | undefined;
|
||||
onBirak: (id: string, konum: Nokta, hiz: Nokta, genislik: number) => boolean;
|
||||
onFirlatildi: (id: string, cikis: Cikis) => void;
|
||||
onGitti: (id: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const kartRef = useRef<HTMLDivElement>(null);
|
||||
// Geri alınan kart ekranı terk ettiği yerden döner; hareketi azalt'ta yerinde belirir.
|
||||
const yol = donus && !azalt ? donus : undefined;
|
||||
const x = useMotionValue(yol?.x ?? 0);
|
||||
const y = useMotionValue(yol?.y ?? 0);
|
||||
const egim = useMotionValue(yol?.egim ?? 1);
|
||||
const opaklik = useMotionValue(donus ? 0 : 1);
|
||||
const donme = useTransform([x, egim], ([kayma, isaret]: number[]) => kayma * DONME * isaret);
|
||||
// Derinlik yuvasına yayla gider; üst kart çekilirken arkadakiler kalkmaya başlar.
|
||||
const derinlikHedefi = useTransform(kalkis, (v) => derinlik - (derinlik > 0 ? v * KALKIS : 0));
|
||||
const derinlikYayi = useSpring(derinlikHedefi, YAY.akici);
|
||||
const gorunen = azalt ? derinlikHedefi : derinlikYayi;
|
||||
const olcek = useTransform(gorunen, (v) => 1 - v * KUCULME);
|
||||
const inis = useTransform(gorunen, (v) => v * PAY);
|
||||
const varlik = useTransform(gorunen, [GORUNEN - 2, GORUNEN - 1], [1, 0]);
|
||||
const golge = useTransform(gorunen, [0, 2], [0, 1]);
|
||||
const sagDamga = useTransform(x, [16, 96], [0, 1]);
|
||||
const solDamga = useTransform(x, [-96, -16], [1, 0]);
|
||||
const surukleme = useRef<{
|
||||
ox: number;
|
||||
oy: number;
|
||||
ornekler: { t: number; x: number; y: number }[];
|
||||
} | null>(null);
|
||||
const oturdu = useRef(!donus);
|
||||
const kararVerildi = useRef(false);
|
||||
// Eve uçan kart desteyi rahat bırakır; arkadakiler tek harekette yer açar.
|
||||
const eveGidiyor = useRef(false);
|
||||
|
||||
// Üst kart ne kadar yol aldığını bildirir: arkadaki deste sürüklemeye ve fırlatmaya karşılık verir.
|
||||
useEffect(() => {
|
||||
if (!etkin) return;
|
||||
const guncelle = () => {
|
||||
if (!eveGidiyor.current) {
|
||||
kalkis.set(birim(Math.hypot(x.get(), y.get() * 0.5) / KALKIS_MESAFESI));
|
||||
}
|
||||
};
|
||||
const xBirak = x.on("change", guncelle);
|
||||
const yBirak = y.on("change", guncelle);
|
||||
return () => {
|
||||
xBirak();
|
||||
yBirak();
|
||||
};
|
||||
}, [etkin, kalkis, x, y]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (karar) {
|
||||
// Tuş ya da düğme kartı sürüklenirken de karara bağlayabilir; fırlatma parmaktan devralır.
|
||||
surukleme.current = null;
|
||||
eveGidiyor.current = false;
|
||||
kartRef.current?.removeAttribute("data-tutuluyor");
|
||||
kararVerildi.current = true;
|
||||
const yon = karar === "sag" ? 1 : -1;
|
||||
const genislik = kartRef.current?.offsetWidth ?? 320;
|
||||
const cx = x.get();
|
||||
const cy = y.get();
|
||||
// Düğme ve tuşlar varsayılan fiskeyle atar; sürükleme parmağın hızını korur.
|
||||
const hiz = firlatmaHizi(id) ?? { x: yon * 900, y: -120 };
|
||||
const surat = Math.hypot(hiz.x, hiz.y);
|
||||
// Fiske seçilen yöne gidiyorsa onun boyunca, değilse sürükleme boyunca, o da yoksa dümdüz çık.
|
||||
let ux = hiz.x;
|
||||
let uy = hiz.y;
|
||||
if (surat < 200 || Math.sign(ux) !== yon) {
|
||||
ux = Math.abs(cx) > 8 ? cx : yon;
|
||||
uy = Math.abs(cx) > 8 ? cy * 0.5 : -0.12;
|
||||
}
|
||||
const boy = Math.hypot(ux, uy) || 1;
|
||||
ux /= boy;
|
||||
uy /= boy;
|
||||
if (Math.abs(ux) < 0.45) {
|
||||
ux = yon * 0.45;
|
||||
uy = Math.sign(uy || -1) * Math.sqrt(1 - 0.45 * 0.45);
|
||||
}
|
||||
const erim = genislik * 1.6;
|
||||
const cikis = { x: cx + ux * erim, y: cy + uy * erim, egim: egim.get() };
|
||||
onFirlatildi(id, cikis);
|
||||
if (azalt) {
|
||||
animate(opaklik, 0, { ...SADE_SOLMA, onComplete: () => onGitti(id) });
|
||||
return;
|
||||
}
|
||||
animate(x, cikis.x, { ...FIRLATMA, velocity: hiz.x });
|
||||
animate(y, cikis.y, { ...FIRLATMA, velocity: hiz.y });
|
||||
animate(opaklik, 0, { ...UCARKEN_SOLMA, onComplete: () => onGitti(id) });
|
||||
return;
|
||||
}
|
||||
// Geri al ya da çıktığı yerden ilk montaj: oradan dön, süren uçuşu yakala.
|
||||
if (!kararVerildi.current && oturdu.current) return;
|
||||
kararVerildi.current = false;
|
||||
oturdu.current = true;
|
||||
const gecikme = donus?.gecikme ?? 0;
|
||||
if (azalt) {
|
||||
x.jump(0);
|
||||
y.jump(0);
|
||||
animate(opaklik, 1, { ...SADE_SOLMA, delay: gecikme });
|
||||
return;
|
||||
}
|
||||
eveGidiyor.current = true;
|
||||
animate(x, 0, {
|
||||
...YAY.akici,
|
||||
delay: gecikme,
|
||||
onComplete: () => {
|
||||
eveGidiyor.current = false;
|
||||
},
|
||||
});
|
||||
animate(y, 0, { ...YAY.akici, delay: gecikme });
|
||||
animate(opaklik, 1, { duration: SURE.hizli, delay: gecikme, ease: EGRI.giris });
|
||||
// Bu etkiyi yalnız karar sürer; dönüş konumu o anda bir kez okunur.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [karar]);
|
||||
|
||||
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!etkin || e.button !== 0 || !e.isPrimary) return;
|
||||
if (
|
||||
e.target instanceof Element &&
|
||||
e.target.closest("button, a, input, select, textarea, [role='button']")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
x.stop();
|
||||
y.stop();
|
||||
eveGidiyor.current = false;
|
||||
// Üst yarıdan tutulan kart elin gittiği yöne, alt yarıdan tutulan ters yöne eğilir.
|
||||
if (Math.abs(x.get()) < 2) {
|
||||
const kutu = e.currentTarget.getBoundingClientRect();
|
||||
egim.set(e.clientY < kutu.top + kutu.height / 2 ? 1 : -1);
|
||||
}
|
||||
surukleme.current = {
|
||||
ox: e.clientX - x.get(),
|
||||
oy: e.clientY - direnYTersi(y.get()),
|
||||
ornekler: [{ t: e.timeStamp, x: e.clientX, y: e.clientY }],
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.setAttribute("data-tutuluyor", "");
|
||||
}
|
||||
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
const s = surukleme.current;
|
||||
if (!s || !etkin) return;
|
||||
x.set(e.clientX - s.ox);
|
||||
y.set(direnY(e.clientY - s.oy));
|
||||
s.ornekler.push({ t: e.timeStamp, x: e.clientX, y: e.clientY });
|
||||
if (s.ornekler.length > 12) s.ornekler.shift();
|
||||
}
|
||||
function birakildi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
const s = surukleme.current;
|
||||
surukleme.current = null;
|
||||
e.currentTarget.removeAttribute("data-tutuluyor");
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
if (!s || !etkin) return;
|
||||
const hiz =
|
||||
e.type === "pointercancel" ? { x: 0, y: 0 } : hizBul(s.ornekler, e.timeStamp);
|
||||
if (onBirak(id, { x: x.get(), y: y.get() }, hiz, e.currentTarget.offsetWidth)) return;
|
||||
// Eşiğin altında kart bırakma hızını koruyarak yerine döner.
|
||||
if (azalt) {
|
||||
x.jump(0);
|
||||
y.jump(0);
|
||||
return;
|
||||
}
|
||||
animate(x, 0, { ...YAY.birakma, velocity: hiz.x });
|
||||
animate(y, 0, { ...YAY.birakma, velocity: hiz.y });
|
||||
}
|
||||
|
||||
const ucuyor = karar !== null;
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-(--kart-boyu) origin-bottom"
|
||||
style={{ zIndex, scale: olcek, y: inis, opacity: varlik }}
|
||||
aria-hidden={etkin ? undefined : true}
|
||||
inert={!etkin}
|
||||
>
|
||||
<motion.div
|
||||
ref={kartRef}
|
||||
// Duran kart kenarlığında düz durur; eldeki ya da uçan kart gölge alır.
|
||||
className={cn(
|
||||
"group/kart absolute inset-0 touch-pan-y rounded-3xl select-none before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:opacity-0 before:shadow-lg before:shadow-slate-900/10 before:transition-opacity before:duration-150 data-[tutuluyor]:cursor-grabbing data-[tutuluyor]:before:opacity-100",
|
||||
etkin ? "pointer-events-auto cursor-grab" : "pointer-events-none",
|
||||
ucuyor && "before:opacity-100",
|
||||
)}
|
||||
style={{ x, y, rotate: donme, opacity: opaklik }}
|
||||
role={etkin ? "group" : undefined}
|
||||
aria-label={etkin ? ad : undefined}
|
||||
onPointerDown={basildi}
|
||||
onPointerMove={oynadi}
|
||||
onPointerUp={birakildi}
|
||||
onPointerCancel={birakildi}
|
||||
>
|
||||
<div className="relative h-full overflow-hidden rounded-[inherit] border border-slate-200 bg-card text-slate-900">
|
||||
{children}
|
||||
<motion.span
|
||||
className="pointer-events-none absolute inset-0 bg-slate-900/5"
|
||||
style={{ opacity: golge }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
{etkin || ucuyor ? (
|
||||
<>
|
||||
<motion.span
|
||||
className="pointer-events-none absolute top-4 left-4 inline-flex h-9 items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 pr-3.5 pl-2.5 text-sm font-semibold whitespace-nowrap text-primary"
|
||||
style={{ opacity: sagDamga }}
|
||||
aria-hidden
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{etiketler.sag}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="pointer-events-none absolute top-4 right-4 inline-flex h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 pr-3.5 pl-2.5 text-sm font-semibold whitespace-nowrap text-slate-700"
|
||||
style={{ opacity: solDamga }}
|
||||
aria-hidden
|
||||
>
|
||||
<X className="size-4" />
|
||||
{etiketler.sol}
|
||||
</motion.span>
|
||||
</>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const DUGME =
|
||||
"inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full border px-4 text-sm font-semibold whitespace-nowrap outline-none transition-[background-color,border-color,color,opacity,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:active:scale-100";
|
||||
const DUGME_TON = {
|
||||
ikincil:
|
||||
"border-slate-200 bg-card text-slate-800 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 aria-disabled:hover:border-slate-200 aria-disabled:hover:bg-card aria-disabled:hover:text-slate-800",
|
||||
sessiz:
|
||||
"border-transparent text-slate-500 hover:text-orange-500 aria-disabled:hover:text-slate-500",
|
||||
onay: "border-primary bg-primary text-white hover:bg-primary/90",
|
||||
} as const;
|
||||
|
||||
function Dugme({
|
||||
ton,
|
||||
pasif,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
ton: keyof typeof DUGME_TON;
|
||||
pasif: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// aria-disabled: deste bitince odak düğmede kalır, sayfaya düşmez.
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(DUGME, DUGME_TON[ton])}
|
||||
aria-disabled={pasif || undefined}
|
||||
onClick={() => {
|
||||
if (!pasif) onClick();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Özel boş durumu çizer; sıfırlama prop olarak gider ki çizim destenin ref'lerine dokunmasın. */
|
||||
function BosYuva({
|
||||
ciz,
|
||||
onSifirla,
|
||||
}: {
|
||||
ciz: (sifirla: () => void) => ReactNode;
|
||||
onSifirla: () => void;
|
||||
}) {
|
||||
return <>{ciz(onSifirla)}</>;
|
||||
}
|
||||
|
||||
export function KartDestesi<T>({
|
||||
ogeler,
|
||||
anahtar,
|
||||
ad,
|
||||
kart,
|
||||
onKarar,
|
||||
onGeriAl,
|
||||
onSifirla,
|
||||
etiketler = { sol: "Geç", sag: "Al" },
|
||||
etiket = "Kartlar",
|
||||
bos,
|
||||
className,
|
||||
}: {
|
||||
ogeler: T[];
|
||||
anahtar: (oge: T) => string;
|
||||
/** Kartın kısa adı; erişilebilir etikette ve duyurularda kullanılır. */
|
||||
ad: (oge: T) => string;
|
||||
kart: (oge: T) => ReactNode;
|
||||
onKarar?: (oge: T, karar: KartKarari) => void;
|
||||
onGeriAl?: (oge: T, karar: KartKarari) => void;
|
||||
onSifirla?: () => void;
|
||||
/** İki yönün eylem adı: düğmelerde ve sürüklerken beliren damgalarda. */
|
||||
etiketler?: { sol: string; sag: string };
|
||||
etiket?: string;
|
||||
/** Tüm kartlar bitince gösterilir; verilen fonksiyon desteyi geri getirir. */
|
||||
bos?: (sifirla: () => void) => ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const hidrate = useSyncExternalStore(bosAbone, () => true, () => false);
|
||||
const azalt = !!useReducedMotion() && hidrate;
|
||||
const ipucuId = useId();
|
||||
const [konum, setKonum] = useState(0);
|
||||
const [gecmis, setGecmis] = useState<{ anahtar: string; karar: KartKarari }[]>([]);
|
||||
const [ucanlar, setUcanlar] = useState<string[]>([]);
|
||||
const [donenler, setDonenler] = useState<Record<string, Donus>>({});
|
||||
const [duyuru, setDuyuru] = useState("");
|
||||
const kalkis = useMotionValue(0);
|
||||
// Fırlatılan kartların ekranı terk ettiği yer: geri al ve baştan başla onları oradan getirir.
|
||||
const cikislar = useRef<Record<string, Cikis>>({});
|
||||
const fiskeler = useRef(new Map<string, Nokta>());
|
||||
const ust = ogeler[konum];
|
||||
const kalan = Math.max(0, ogeler.length - konum);
|
||||
|
||||
function kararVer(karar: KartKarari) {
|
||||
if (!ust) return;
|
||||
const k = anahtar(ust);
|
||||
kalkis.set(0);
|
||||
setKonum(konum + 1);
|
||||
setGecmis([...gecmis, { anahtar: k, karar }]);
|
||||
setUcanlar((simdiki) => [...simdiki, k]);
|
||||
setDonenler({});
|
||||
const geriye = kalan - 1;
|
||||
setDuyuru(
|
||||
`${ad(ust)}: ${etiketler[karar]}. ${geriye ? `${geriye} kart kaldı.` : "Kartlar bitti."}`,
|
||||
);
|
||||
onKarar?.(ust, karar);
|
||||
}
|
||||
|
||||
function geriAl() {
|
||||
const son = gecmis[gecmis.length - 1];
|
||||
const oge = son && ogeler.find((o) => anahtar(o) === son.anahtar);
|
||||
if (!son || !oge) return;
|
||||
const cikis = cikislar.current[son.anahtar];
|
||||
setKonum(konum - 1);
|
||||
setGecmis(gecmis.slice(0, -1));
|
||||
setUcanlar((simdiki) => simdiki.filter((k) => k !== son.anahtar));
|
||||
setDonenler(cikis ? { [son.anahtar]: { ...cikis, gecikme: 0 } } : {});
|
||||
setDuyuru(`${ad(oge)} geri geldi.`);
|
||||
onGeriAl?.(oge, son.karar);
|
||||
}
|
||||
|
||||
function sifirla() {
|
||||
if (!gecmis.length) return;
|
||||
// Deste kendini geri dağıtır: en derinde duracak kart önce, üstteki en son döner.
|
||||
const adet = Math.min(GORUNEN, ogeler.length);
|
||||
const geri: Record<string, Donus> = {};
|
||||
ogeler.slice(0, adet).forEach((oge, i) => {
|
||||
const k = anahtar(oge);
|
||||
const cikis = cikislar.current[k];
|
||||
if (cikis && i < konum) geri[k] = { ...cikis, gecikme: (adet - 1 - i) * 0.06 };
|
||||
});
|
||||
setKonum(0);
|
||||
setGecmis([]);
|
||||
setUcanlar([]);
|
||||
setDonenler(geri);
|
||||
setDuyuru("Kartların hepsi destede.");
|
||||
onSifirla?.();
|
||||
}
|
||||
|
||||
function birak(k: string, yer: Nokta, hiz: Nokta, genislik: number) {
|
||||
const yon = Math.sign(yer.x);
|
||||
if (!yon || !ust || anahtar(ust) !== k) return false;
|
||||
const fiske = Math.abs(hiz.x) > FISKE && Math.sign(hiz.x) === yon && Math.abs(yer.x) > 12;
|
||||
const uzak =
|
||||
Math.abs(yer.x) > Math.min(genislik * 0.35, 140) &&
|
||||
!(Math.sign(hiz.x) === -yon && Math.abs(hiz.x) > 300);
|
||||
if (!fiske && !uzak) return false;
|
||||
fiskeler.current.set(k, hiz);
|
||||
kararVer(yon > 0 ? "sag" : "sol");
|
||||
return true;
|
||||
}
|
||||
|
||||
function tus(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
kararVer(e.key === "ArrowRight" ? "sag" : "sol");
|
||||
} else if (
|
||||
e.key === "Backspace" ||
|
||||
((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
|
||||
) {
|
||||
e.preventDefault();
|
||||
geriAl();
|
||||
}
|
||||
}
|
||||
|
||||
const kararlar = new Map(gecmis.map((g) => [g.anahtar, g.karar]));
|
||||
const pencere = new Set(ogeler.slice(konum, konum + GORUNEN).map(anahtar));
|
||||
const cizilenler = ogeler.filter(
|
||||
(o) => pencere.has(anahtar(o)) || ucanlar.includes(anahtar(o)),
|
||||
);
|
||||
const bitti = konum >= ogeler.length && ucanlar.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full justify-items-center gap-5 [--kart-boyu:19rem]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="group/sahne relative isolate h-[calc(var(--kart-boyu)+24px)] w-full max-w-80 rounded-3xl outline-none"
|
||||
role="group"
|
||||
aria-roledescription="kart destesi"
|
||||
aria-label={etiket}
|
||||
aria-describedby={ipucuId}
|
||||
tabIndex={0}
|
||||
onKeyDown={tus}
|
||||
>
|
||||
{cizilenler.map((oge) => {
|
||||
const id = anahtar(oge);
|
||||
const sira = ogeler.indexOf(oge);
|
||||
const karar = kararlar.get(id) ?? null;
|
||||
const derinlik = karar ? 0 : sira - konum;
|
||||
return (
|
||||
<DesteKarti
|
||||
key={id}
|
||||
id={id}
|
||||
derinlik={derinlik}
|
||||
karar={karar}
|
||||
donus={donenler[id]}
|
||||
etkin={!karar && derinlik === 0}
|
||||
zIndex={karar ? 20 + ucanlar.indexOf(id) : GORUNEN - derinlik}
|
||||
ad={`${ad(oge)}, ${sira + 1} / ${ogeler.length}`}
|
||||
etiketler={etiketler}
|
||||
kalkis={kalkis}
|
||||
azalt={azalt}
|
||||
firlatmaHizi={(k) => {
|
||||
const hiz = fiskeler.current.get(k);
|
||||
fiskeler.current.delete(k);
|
||||
return hiz;
|
||||
}}
|
||||
onBirak={birak}
|
||||
onFirlatildi={(k, cikis) => {
|
||||
cikislar.current[k] = cikis;
|
||||
}}
|
||||
onGitti={(k) => setUcanlar((simdiki) => simdiki.filter((u) => u !== k))}
|
||||
>
|
||||
{kart(oge)}
|
||||
</DesteKarti>
|
||||
);
|
||||
})}
|
||||
<AnimatePresence initial={false}>
|
||||
{bitti ? (
|
||||
<motion.div
|
||||
key="bos"
|
||||
className="absolute inset-x-0 top-0 grid h-(--kart-boyu) place-content-center justify-items-center gap-3 rounded-3xl border border-slate-200 bg-card p-6 text-center"
|
||||
initial={azalt ? { opacity: 0 } : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, transition: { duration: SURE.anlik } }}
|
||||
transition={azalt ? SADE_SOLMA : { duration: SURE.standart, ease: EGRI.giris }}
|
||||
>
|
||||
{bos ? (
|
||||
<BosYuva ciz={bos} onSifirla={sifirla} />
|
||||
) : (
|
||||
<>
|
||||
<p className="font-heading text-lg font-bold text-slate-900">Kartlar bitti</p>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(DUGME, DUGME_TON.ikincil)}
|
||||
onClick={sifirla}
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Baştan başla
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{/* Odak halkası sahnenin değil üst kartın çevresinde görünür */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 hidden h-(--kart-boyu) rounded-3xl ring-3 ring-ring/50 group-focus-visible/sahne:block"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-nowrap justify-center gap-2">
|
||||
<Dugme ton="ikincil" pasif={!ust} onClick={() => kararVer("sol")}>
|
||||
<X className="size-4" aria-hidden />
|
||||
{etiketler.sol}
|
||||
</Dugme>
|
||||
<Dugme ton="sessiz" pasif={!gecmis.length} onClick={geriAl}>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Geri al
|
||||
</Dugme>
|
||||
<Dugme ton="onay" pasif={!ust} onClick={() => kararVer("sag")}>
|
||||
<Check className="size-4" aria-hidden />
|
||||
{etiketler.sag}
|
||||
</Dugme>
|
||||
</div>
|
||||
<p id={ipucuId} className="sr-only">
|
||||
Üstteki kartı sürükle ya da sol ve sağ ok tuşlarıyla karar ver. Geri tuşu son kararı
|
||||
geri alır.
|
||||
</p>
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{duyuru}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
528
src/components/ui/kaydirma-eylemi.tsx
Normal file
528
src/components/ui/kaydirma-eylemi.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
// Kaydırma eylemi: liste satırını yana kaydırınca altından eylemler çıkar
|
||||
// (posta kutusu gibi). Satır parmağı birebir izler; eylem genişliğinde
|
||||
// durur, sonuna kadar çekilince en dıştaki eylem tüm satırı kaplar ve
|
||||
// bırakınca uygulanır. Aynı anda tek satır açık kalır; başka yere dokunmak ya
|
||||
// da Escape açık satırı kapatır.
|
||||
//
|
||||
// Kaynak: Arc UI "Swipe actions" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
|
||||
// - Arc'taki "Diğer eylemler" menüsü alınmadı: eylem, satırda zaten görünen
|
||||
// bir düğmeyle de yapılabilmelidir (klavye ve ekran okuyucu yolu odur;
|
||||
// kaydırılan kopya `aria-hidden`).
|
||||
// - Satırın listeden çıkışını çağıran yönetir (`AnimatePresence`); bu bileşen
|
||||
// yalnız kaydırmayı ve eylemin satırı kaplamasını çizer.
|
||||
// - Renkler palet token'ı (§2), yay sekmesiz; bırakışta tek hafif sekme (§6).
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type AnimationPlaybackControls,
|
||||
type MotionValue,
|
||||
} from "motion/react";
|
||||
import { YAY, lastik } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface KaydirmaEylemi {
|
||||
etiket: string;
|
||||
ikon: ReactNode;
|
||||
/** `tehlike`: geri dönüşü olan silme/çıkarma; etiket sonucu adıyla söyler. */
|
||||
ton?: "notr" | "vurgu" | "tehlike";
|
||||
/** Tam kaydırmada ya da açılan eyleme dokununca çalışır. */
|
||||
onSec: () => void;
|
||||
/** Eylemden sonra satır kayıp gitmez, yerine döner (ör. "Başa al"). */
|
||||
satirKalir?: boolean;
|
||||
}
|
||||
|
||||
type Yan = "sol" | "sag";
|
||||
type Grup = {
|
||||
acikId: string | null;
|
||||
setAcikId: Dispatch<SetStateAction<string | null>>;
|
||||
satirlar: Map<string, HTMLElement>;
|
||||
};
|
||||
type Surukleme = {
|
||||
isaretci: number;
|
||||
tur: string;
|
||||
basX: number;
|
||||
basY: number;
|
||||
koken: number;
|
||||
kilit: "x" | "y" | null;
|
||||
ornekler: [number, number][];
|
||||
};
|
||||
|
||||
/** Satır açık dururken tek eylemin genişliği (px). */
|
||||
const EN = 80;
|
||||
/** Bu kadar oynamadan basış kaydırma sayılmaz; dikeyse sayfanın kaydırmasına bırakılır. */
|
||||
const BOSLUK = 8;
|
||||
/** Bırakma hızının götüreceği yol (sn). */
|
||||
const SUZULME = 0.1;
|
||||
/** Bundan hızlı bırakış (px/sn) fiske sayılır. */
|
||||
const FISKE = 900;
|
||||
|
||||
const TON: Record<NonNullable<KaydirmaEylemi["ton"]>, string> = {
|
||||
notr: "bg-slate-200 text-slate-800",
|
||||
vurgu: "bg-primary text-white",
|
||||
tehlike: "bg-red-500 text-white",
|
||||
};
|
||||
|
||||
const birim = (v: number) => Math.min(1, Math.max(0, v));
|
||||
const yanBul = (v: number): Yan | null => (v > 0 ? "sol" : v < 0 ? "sag" : null);
|
||||
|
||||
const GrupBaglami = createContext<Grup>({
|
||||
acikId: null,
|
||||
setAcikId: () => {},
|
||||
satirlar: new Map(),
|
||||
});
|
||||
|
||||
/** Satırları sarar: aynı anda yalnız biri açık kalır. DOM üretmez. */
|
||||
export function KaydirmaGrubu({ children }: { children: ReactNode }) {
|
||||
const [acikId, setAcikId] = useState<string | null>(null);
|
||||
const [satirlar] = useState(() => new Map<string, HTMLElement>());
|
||||
useEffect(() => {
|
||||
if (!acikId) return;
|
||||
const basildi = (e: PointerEvent) => {
|
||||
const satir = satirlar.get(acikId);
|
||||
if (satir && e.target instanceof Node && satir.contains(e.target)) return;
|
||||
setAcikId(null);
|
||||
};
|
||||
const tus = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAcikId(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", basildi, true);
|
||||
document.addEventListener("keydown", tus);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", basildi, true);
|
||||
document.removeEventListener("keydown", tus);
|
||||
};
|
||||
}, [acikId, satirlar]);
|
||||
const grup = useMemo(() => ({ acikId, setAcikId, satirlar }), [acikId, satirlar]);
|
||||
return <GrupBaglami.Provider value={grup}>{children}</GrupBaglami.Provider>;
|
||||
}
|
||||
|
||||
function hizBul(ornekler: [number, number][]) {
|
||||
const son = ornekler[ornekler.length - 1];
|
||||
const ilk = ornekler.find((o) => son[0] - o[0] <= 80) ?? son;
|
||||
const gecen = (son[0] - ilk[0]) / 1000;
|
||||
return gecen > 0 ? (son[1] - ilk[1]) / gecen : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tek satır. `sol` eylemler sol kenarın, `sag` eylemler sağ kenarın altında
|
||||
* durur; her yanda en dıştaki eylem tam kaydırmayla uygulanır.
|
||||
*/
|
||||
export function KaydirmaSatiri({
|
||||
sol = [],
|
||||
sag = [],
|
||||
tamKaydirma = true,
|
||||
children,
|
||||
className,
|
||||
icerikSinifi,
|
||||
}: {
|
||||
sol?: KaydirmaEylemi[];
|
||||
sag?: KaydirmaEylemi[];
|
||||
/** Uzun kaydırma en dıştaki eylemi dokunmadan uygular. */
|
||||
tamKaydirma?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Kayan içerik kabı: zemin (`bg-card`) ve iç yerleşim buradan. */
|
||||
icerikSinifi?: string;
|
||||
}) {
|
||||
const { acikId, setAcikId, satirlar } = useContext(GrupBaglami);
|
||||
const id = useId();
|
||||
const azalt = useReducedMotion() ?? false;
|
||||
const satirRef = useRef<HTMLDivElement>(null);
|
||||
const x = useMotionValue(0);
|
||||
const kaplama = useMotionValue(0);
|
||||
const [kaplayan, setKaplayan] = useState<{ yan: Yan; sira: number } | null>(null);
|
||||
const genislik = useRef(0);
|
||||
const surukleme = useRef<Surukleme | null>(null);
|
||||
const yolculuk = useRef<AnimationPlaybackControls | null>(null);
|
||||
const gerilme = useRef<AnimationPlaybackControls | null>(null);
|
||||
const kurulu = useRef(false);
|
||||
const ayriliyor = useRef(false);
|
||||
const tiklamayiYut = useRef(false);
|
||||
const geriZamanlayici = useRef(0);
|
||||
|
||||
const eylemler = (yan: Yan) => (yan === "sol" ? sol : sag);
|
||||
const acikEn = (yan: Yan) => eylemler(yan).length * EN;
|
||||
const enDis = (yan: Yan) => (yan === "sol" ? 0 : sag.length - 1);
|
||||
const uygulanabilir = (yan: Yan) => tamKaydirma && eylemler(yan).length > 0;
|
||||
const esik = (yan: Yan) => Math.max(acikEn(yan) + 48, genislik.current * 0.56);
|
||||
|
||||
function ger(deger: number) {
|
||||
gerilme.current?.stop();
|
||||
if (azalt) {
|
||||
kaplama.jump(deger);
|
||||
return;
|
||||
}
|
||||
gerilme.current = animate(kaplama, deger, YAY.donusum);
|
||||
}
|
||||
|
||||
/** Satırı bir durağa yayla oturtur; bırakma hızını taşır. */
|
||||
function otur(hedef: number, hiz = 0) {
|
||||
if (kurulu.current || (hedef !== 0 && kaplama.get() !== 0)) ger(0);
|
||||
kurulu.current = false;
|
||||
const nereden = x.get();
|
||||
const evde = () => {
|
||||
if (hedef === 0) {
|
||||
gerilme.current?.stop();
|
||||
kaplama.jump(0);
|
||||
}
|
||||
};
|
||||
if (azalt) {
|
||||
x.jump(hedef);
|
||||
evde();
|
||||
} else {
|
||||
// Eve dönerken yay satırı sıfırın ötesine taşımamalı; karşı yanın eylemleri parlar.
|
||||
const eveDogru = hedef === 0 && Math.sign(hiz) === -Math.sign(nereden);
|
||||
const cikis = eveDogru
|
||||
? Math.sign(hiz) * Math.min(Math.abs(hiz), Math.abs(nereden) * 12)
|
||||
: hiz;
|
||||
yolculuk.current = animate(x, hedef, {
|
||||
...(hedef === 0 ? YAY.akici : YAY.birakma),
|
||||
velocity: cikis,
|
||||
onComplete: evde,
|
||||
});
|
||||
}
|
||||
if (hedef === 0) setAcikId((simdiki) => (simdiki === id ? null : simdiki));
|
||||
else setAcikId(id);
|
||||
}
|
||||
|
||||
/** Seçilen eylem tüm satırı kaplarken satır onun yönünde çıkar. */
|
||||
function uygula(yan: Yan, sira: number, hiz = 0) {
|
||||
const eylem = eylemler(yan)[sira];
|
||||
if (!eylem || ayriliyor.current) return;
|
||||
const yon = yan === "sol" ? 1 : -1;
|
||||
kurulu.current = false;
|
||||
setKaplayan({ yan, sira });
|
||||
ger(1);
|
||||
if (eylem.satirKalir) {
|
||||
eylem.onSec();
|
||||
otur(0, hiz);
|
||||
return;
|
||||
}
|
||||
ayriliyor.current = true;
|
||||
yolculuk.current?.stop();
|
||||
const son = yon * (genislik.current + 2);
|
||||
if (azalt) x.jump(son);
|
||||
else {
|
||||
yolculuk.current = animate(x, son, {
|
||||
...YAY.akici,
|
||||
velocity: Math.sign(hiz) === yon ? hiz : 0,
|
||||
});
|
||||
}
|
||||
setAcikId((simdiki) => (simdiki === id ? null : simdiki));
|
||||
eylem.onSec();
|
||||
// Öğeyi listede tutan çağıran, boş bir renk bandı yerine satırını geri alır.
|
||||
window.clearTimeout(geriZamanlayici.current);
|
||||
geriZamanlayici.current = window.setTimeout(() => {
|
||||
if (!ayriliyor.current) return;
|
||||
ayriliyor.current = false;
|
||||
otur(0);
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function kur(deger: number, isaretciTuru: string) {
|
||||
const yan = yanBul(deger);
|
||||
const sinir = yan ? esik(yan) : Infinity;
|
||||
const sonraki =
|
||||
!!yan && uygulanabilir(yan) && Math.abs(deger) > (kurulu.current ? sinir - 20 : sinir);
|
||||
if (sonraki === kurulu.current || !yan) {
|
||||
if (!yan && kurulu.current) {
|
||||
kurulu.current = false;
|
||||
ger(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
kurulu.current = sonraki;
|
||||
if (sonraki) {
|
||||
setKaplayan({ yan, sira: enDis(yan) });
|
||||
if (isaretciTuru === "touch") navigator.vibrate?.(8);
|
||||
}
|
||||
ger(sonraki ? 1 : 0);
|
||||
}
|
||||
|
||||
function sinirla(ham: number) {
|
||||
const yan = yanBul(ham);
|
||||
if (!yan) return 0;
|
||||
const olcu = genislik.current || 320;
|
||||
const sinir =
|
||||
eylemler(yan).length === 0 ? 0 : uygulanabilir(yan) ? olcu : acikEn(yan);
|
||||
const mesafe = Math.abs(ham);
|
||||
return Math.sign(ham) * (mesafe <= sinir ? mesafe : sinir + lastik(mesafe - sinir, olcu));
|
||||
}
|
||||
|
||||
function birak(hiz: number) {
|
||||
const deger = x.get();
|
||||
const yan = yanBul(deger);
|
||||
if (!yan || eylemler(yan).length === 0) {
|
||||
otur(0, hiz);
|
||||
return;
|
||||
}
|
||||
const yon = yan === "sol" ? 1 : -1;
|
||||
const mesafe = Math.abs(deger);
|
||||
const disari = hiz * yon;
|
||||
const acik = acikEn(yan);
|
||||
const varis = mesafe + disari * SUZULME;
|
||||
const firlatildi = mesafe > acik && disari > FISKE && varis > esik(yan);
|
||||
// Eve doğru fiske, kurulmuş tam kaydırmayı iptal eder.
|
||||
if (uygulanabilir(yan) && ((kurulu.current && disari > -FISKE) || firlatildi)) {
|
||||
uygula(yan, enDis(yan), hiz);
|
||||
return;
|
||||
}
|
||||
otur(varis > acik / 2 ? yon * acik : 0, hiz);
|
||||
}
|
||||
|
||||
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
tiklamayiYut.current = false;
|
||||
if (ayriliyor.current || !e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) {
|
||||
return;
|
||||
}
|
||||
// Satırı uçarken yakala: sonraki hareket bulunduğu yerden sürer.
|
||||
yolculuk.current?.stop();
|
||||
surukleme.current = {
|
||||
isaretci: e.pointerId,
|
||||
tur: e.pointerType,
|
||||
basX: e.clientX,
|
||||
basY: e.clientY,
|
||||
koken: x.get(),
|
||||
kilit: null,
|
||||
ornekler: [[e.timeStamp, e.clientX]],
|
||||
};
|
||||
}
|
||||
|
||||
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
const s = surukleme.current;
|
||||
if (!s || s.isaretci !== e.pointerId || s.kilit === "y") return;
|
||||
if (!s.kilit) {
|
||||
const dx = e.clientX - s.basX;
|
||||
const dy = e.clientY - s.basY;
|
||||
if (Math.hypot(dx, dy) < BOSLUK) return;
|
||||
if (Math.abs(dy) > Math.abs(dx)) {
|
||||
s.kilit = "y";
|
||||
return;
|
||||
}
|
||||
// Buradan ölç: boşluğu aşarken satır sıçramaz.
|
||||
s.kilit = "x";
|
||||
s.basX = e.clientX;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setAcikId(id);
|
||||
}
|
||||
const deger = sinirla(s.koken + e.clientX - s.basX);
|
||||
x.set(deger);
|
||||
s.ornekler.push([e.timeStamp, e.clientX]);
|
||||
if (s.ornekler.length > 12) s.ornekler.shift();
|
||||
kur(deger, s.tur);
|
||||
}
|
||||
|
||||
function bitti(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
const s = surukleme.current;
|
||||
if (!s || s.isaretci !== e.pointerId) return;
|
||||
surukleme.current = null;
|
||||
if (s.kilit === "x") {
|
||||
tiklamayiYut.current = true;
|
||||
s.ornekler.push([e.timeStamp, e.clientX]);
|
||||
birak(e.type === "pointercancel" ? 0 : hizBul(s.ornekler));
|
||||
return;
|
||||
}
|
||||
// Açık satıra dokunmak, altındakine basmak yerine satırı kapatır.
|
||||
if (s.kilit === null && Math.abs(x.get()) > 0.5) {
|
||||
tiklamayiYut.current = true;
|
||||
otur(0);
|
||||
}
|
||||
}
|
||||
|
||||
function tiklamaYakala(e: ReactMouseEvent<HTMLDivElement>) {
|
||||
if (!tiklamayiYut.current) return;
|
||||
tiklamayiYut.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const satir = satirRef.current;
|
||||
if (!satir) return;
|
||||
satirlar.set(id, satir);
|
||||
genislik.current = satir.offsetWidth;
|
||||
const gozlemci = new ResizeObserver(([kayit]) => {
|
||||
genislik.current = kayit.contentRect.width;
|
||||
});
|
||||
gozlemci.observe(satir);
|
||||
return () => {
|
||||
gozlemci.disconnect();
|
||||
satirlar.delete(id);
|
||||
window.clearTimeout(geriZamanlayici.current);
|
||||
setAcikId((simdiki) => (simdiki === id ? null : simdiki));
|
||||
};
|
||||
}, [id, satirlar, setAcikId]);
|
||||
|
||||
// Başka satır açıldı ya da dışarı dokunuldu: bu satır kapanır.
|
||||
const kapanRef = useRef(() => {});
|
||||
useEffect(() => {
|
||||
kapanRef.current = () => {
|
||||
if (
|
||||
acikId !== id &&
|
||||
!ayriliyor.current &&
|
||||
surukleme.current?.kilit !== "x" &&
|
||||
x.get() !== 0
|
||||
) {
|
||||
otur(0);
|
||||
}
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
kapanRef.current();
|
||||
}, [acikId]);
|
||||
|
||||
return (
|
||||
<div ref={satirRef} className={cn("relative isolate overflow-hidden", className)}>
|
||||
{(["sol", "sag"] as const).map((yan) =>
|
||||
eylemler(yan).map((eylem, sira) => {
|
||||
const adet = eylemler(yan).length;
|
||||
const kat = yan === "sol" ? adet - 1 - sira : sira;
|
||||
const kaplayanKat =
|
||||
kaplayan?.yan === yan
|
||||
? yan === "sol"
|
||||
? adet - 1 - kaplayan.sira
|
||||
: kaplayan.sira
|
||||
: null;
|
||||
return (
|
||||
<EylemKatmani
|
||||
key={`${yan}-${sira}`}
|
||||
eylem={eylem}
|
||||
yan={yan}
|
||||
kat={kat}
|
||||
adet={adet}
|
||||
kaplayanKat={kaplayanKat}
|
||||
x={x}
|
||||
kaplama={kaplama}
|
||||
onBas={() => uygula(yan, sira)}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
<motion.div
|
||||
// Dikey kaydırma sayfada kalır; içerik parmağı yalnız transform'la izler.
|
||||
className={cn("relative z-10 touch-pan-y select-none", icerikSinifi)}
|
||||
style={{ x }}
|
||||
onPointerDown={basildi}
|
||||
onPointerMove={oynadi}
|
||||
onPointerUp={bitti}
|
||||
onPointerCancel={bitti}
|
||||
onClickCapture={tiklamaYakala}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Açılan tek eylem. Kat 0 içeriğe yaslıdır, en yüksek kat satırın dış
|
||||
* kenarındadır. Her katman satırın tamamı kadardır ve yalnız transform'la
|
||||
* kayar: eylemler açılan alanı eşit paylaşır, kaplayan eylem satır boyunca gerilir.
|
||||
*/
|
||||
function EylemKatmani({
|
||||
eylem,
|
||||
yan,
|
||||
kat,
|
||||
adet,
|
||||
kaplayanKat,
|
||||
x,
|
||||
kaplama,
|
||||
onBas,
|
||||
}: {
|
||||
eylem: KaydirmaEylemi;
|
||||
yan: Yan;
|
||||
kat: number;
|
||||
adet: number;
|
||||
kaplayanKat: number | null;
|
||||
x: MotionValue<number>;
|
||||
kaplama: MotionValue<number>;
|
||||
onBas: () => void;
|
||||
}) {
|
||||
const yon = yan === "sol" ? 1 : -1;
|
||||
const pay = useTransform(x, (v) => Math.max(0, v * yon) / adet);
|
||||
// Satırın dış kenarından bu katmanın iç kenarına uzaklık.
|
||||
const ic = useTransform([x, kaplama], ([v, ilerleme]: number[]) => {
|
||||
const acilan = Math.max(0, v * yon);
|
||||
const taban = ((adet - kat) * acilan) / adet;
|
||||
if (kaplayanKat === kat) return taban + ilerleme * (acilan - taban);
|
||||
if (kaplayanKat !== null && kat > kaplayanKat) return taban * (1 - ilerleme);
|
||||
return taban;
|
||||
});
|
||||
const kayma = useTransform(ic, (v) => yon * v);
|
||||
// İşaret payının ortasında gider; bu eylem satırı kaplarken içerik kenarına yaslanır.
|
||||
const isaretX = useTransform([pay, kaplama], ([p, ilerleme]: number[]) =>
|
||||
-yon * (kaplayanKat === kat ? p / 2 + ilerleme * (EN / 2 - p / 2) : p / 2),
|
||||
);
|
||||
const ikonGorunum = useTransform([pay, kaplama], ([p, ilerleme]: number[]) => {
|
||||
const kendi = birim((p - EN * 0.25) / (EN * 0.55));
|
||||
if (kaplayanKat === kat) return Math.max(kendi, ilerleme);
|
||||
return kaplayanKat === null ? kendi : kendi * (1 - ilerleme);
|
||||
});
|
||||
const etiketGorunum = useTransform([pay, kaplama], ([p, ilerleme]: number[]) => {
|
||||
const kendi = birim((p - EN * 0.72) / (EN * 0.24));
|
||||
if (kaplayanKat === kat) return Math.max(kendi, ilerleme);
|
||||
return kaplayanKat === null ? kendi : kendi * (1 - ilerleme);
|
||||
});
|
||||
const ikonOlcek = useTransform(ikonGorunum, (v) => 0.6 + 0.4 * v);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
// Erişilebilir yol satırdaki görünür düğmedir; bu kopya sekme sırasına ve
|
||||
// erişilebilirlik ağacına girmez.
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-0 w-full cursor-pointer outline-none",
|
||||
yan === "sag" ? "left-full" : "right-full",
|
||||
TON[eylem.ton ?? "notr"],
|
||||
)}
|
||||
style={{ x: kayma, zIndex: kat + 1 }}
|
||||
onClick={onBas}
|
||||
>
|
||||
<motion.span
|
||||
className={cn("absolute inset-y-0 w-0", yan === "sag" ? "left-0" : "right-0")}
|
||||
style={{ x: isaretX }}
|
||||
>
|
||||
<span
|
||||
className="absolute inset-y-0 flex flex-col items-center justify-center gap-0.5"
|
||||
style={{ left: -EN / 2, width: EN }}
|
||||
>
|
||||
<motion.span
|
||||
className="grid place-items-center [&_svg]:size-5"
|
||||
style={{ opacity: ikonGorunum, scale: ikonOlcek }}
|
||||
>
|
||||
{eylem.ikon}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="text-xs font-semibold whitespace-nowrap"
|
||||
style={{ opacity: etiketGorunum }}
|
||||
>
|
||||
{eylem.etiket}
|
||||
</motion.span>
|
||||
</span>
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Bildirimler (DESIGN.md §4.16). Tema sitenin temasını izler (sistemin
|
||||
* değil): sonner kendi "system" algısıyla açık sitede koyu toast metni
|
||||
* basıp açıklamayı okunmaz ediyordu (Bilal, 24 Eyl 2026: "toast açıklaması
|
||||
* okunmuyor"). Metin ve buton renkleri globals.css'te.
|
||||
*/
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
return (
|
||||
<Sonner
|
||||
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<span className="block size-2.5 animate-pulse rounded-full bg-current" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
149
src/components/ui/yazi-donusumu.tsx
Normal file
149
src/components/ui/yazi-donusumu.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
// Yazı dönüşümü (DESIGN.md §4.26): kısa bir ifade yerinde bir sonrakine
|
||||
// dönüşür. İki ifadenin ortak harfleri yeni yerlerine süzülür, yeni harfler
|
||||
// netleşerek gelir, giden harfler bulanıp söner; genişlik yayla izler ki
|
||||
// çevredeki yazı sıçramadan kaysın. Tek satırda kalır; ekran okuyucu düz
|
||||
// metni okur.
|
||||
//
|
||||
// Kaynak: Arc UI "Text morph" (uiarc.dev, 7 Eki 2026). Yay sekmesiz (§6);
|
||||
// hareketi azalt'ta yazı geçişsiz değişir.
|
||||
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
animate,
|
||||
motion,
|
||||
useReducedMotion,
|
||||
type AnimationPlaybackControls,
|
||||
} from "motion/react";
|
||||
import { EGRI, SURE, YAY } from "@/lib/hareket";
|
||||
|
||||
/** Giden harfler, yerlerine ilk yeni harf netleşmeden önce silinmeye başlar. */
|
||||
const DEVIR = 0.05;
|
||||
const HARF_ARALIGI = 0.032;
|
||||
|
||||
/** Harfler karakter + kaçıncı geçişi olduğuyla eşleşir: ikinci "i" ikinci "i" ile. */
|
||||
function harflereBol(metin: string) {
|
||||
const gorulen = new Map<string, number>();
|
||||
return Array.from(metin).map((harf) => {
|
||||
const adet = gorulen.get(harf) ?? 0;
|
||||
gorulen.set(harf, adet + 1);
|
||||
return { harf, anahtar: `${harf}-${adet}` };
|
||||
});
|
||||
}
|
||||
const olc = (el: HTMLElement) => parseFloat(getComputedStyle(el).width);
|
||||
|
||||
export function YaziDonusumu({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const azalt = useReducedMotion();
|
||||
const cerceve = useRef<HTMLSpanElement>(null);
|
||||
const serit = useRef<HTMLSpanElement>(null);
|
||||
const genislik = useRef(0);
|
||||
const boyutlama = useRef<AnimationPlaybackControls | null>(null);
|
||||
const harfler = useMemo(() => harflereBol(children), [children]);
|
||||
// Önceki ifade hangi harflerin yeni olduğunu söyler; yalnız onlar sırayla gelir.
|
||||
const [ifadeler, setIfadeler] = useState({ simdiki: children, onceki: children });
|
||||
if (ifadeler.simdiki !== children) {
|
||||
setIfadeler({ simdiki: children, onceki: ifadeler.simdiki });
|
||||
}
|
||||
const kalanlar = useMemo(
|
||||
() => new Set(harflereBol(ifadeler.onceki).map((h) => h.anahtar)),
|
||||
[ifadeler.onceki],
|
||||
);
|
||||
let giren = 0;
|
||||
|
||||
// Çerçeve açık bir genişlik taşır: yeni ifade onu sıçratmaz, yay şeridi izler.
|
||||
useLayoutEffect(() => {
|
||||
const c = cerceve.current;
|
||||
const s = serit.current;
|
||||
if (!c || !s) return;
|
||||
const sonraki = olc(s);
|
||||
if (!Number.isFinite(sonraki)) return;
|
||||
if (genislik.current && Math.abs(sonraki - genislik.current) > 0.5 && !azalt) {
|
||||
boyutlama.current = animate(c, { width: sonraki }, YAY.donusum);
|
||||
} else {
|
||||
boyutlama.current?.stop();
|
||||
c.style.width = `${sonraki}px`;
|
||||
}
|
||||
genislik.current = sonraki;
|
||||
}, [children, azalt]);
|
||||
|
||||
// Font yüklenmesi ya da duyarlı yazı boyu genişliği ifade değişmeden değiştirir: hemen izle.
|
||||
useEffect(() => {
|
||||
const c = cerceve.current;
|
||||
const s = serit.current;
|
||||
if (!c || !s || typeof ResizeObserver === "undefined") return;
|
||||
const gozlemci = new ResizeObserver(() => {
|
||||
const sonraki = olc(s);
|
||||
if (!Number.isFinite(sonraki) || Math.abs(sonraki - genislik.current) < 0.5) return;
|
||||
boyutlama.current?.stop();
|
||||
c.style.width = `${sonraki}px`;
|
||||
genislik.current = sonraki;
|
||||
});
|
||||
gozlemci.observe(s);
|
||||
return () => gozlemci.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<span className="sr-only">{children}</span>
|
||||
<span
|
||||
ref={cerceve}
|
||||
aria-hidden
|
||||
// Dolgu + eksi kenar boşluğu: maskeye bulanıklık ve kuyruklu harfler
|
||||
// için yer açar, yerleşimi değiştirmez. Şerit hep çerçevenin başından başlar.
|
||||
className="relative -my-[0.5em] -mr-[0.5em] -ml-[0.2em] box-content inline-block py-[0.5em] pr-[0.5em] pl-[0.2em] text-start whitespace-nowrap [mask-image:linear-gradient(90deg,#000_calc(100%-0.5em),transparent)]"
|
||||
>
|
||||
<span ref={serit} className="relative inline-block whitespace-nowrap">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{harfler.map(({ harf, anahtar }) => {
|
||||
const sira = kalanlar.has(anahtar) ? 0 : giren++;
|
||||
return (
|
||||
<motion.span
|
||||
key={anahtar}
|
||||
layout="position"
|
||||
className="inline-block origin-[50%_60%] whitespace-pre"
|
||||
initial={
|
||||
azalt ? false : { opacity: 0, scale: 0.8, y: "0.14em", filter: "blur(4px)" }
|
||||
}
|
||||
animate={{ opacity: 1, scale: 1, y: "0em", filter: "blur(0px)" }}
|
||||
exit={
|
||||
azalt
|
||||
? { opacity: 0, transition: { duration: 0 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.86,
|
||||
y: "-0.1em",
|
||||
filter: "blur(4px)",
|
||||
transition: { duration: SURE.cikis, ease: EGRI.standart },
|
||||
}
|
||||
}
|
||||
transition={
|
||||
azalt
|
||||
? { duration: 0 }
|
||||
: {
|
||||
layout: YAY.donusum,
|
||||
default: {
|
||||
duration: SURE.standart + 0.04,
|
||||
ease: EGRI.giris,
|
||||
delay: DEVIR + Math.min(sira, 8) * HARF_ARALIGI,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{harf === " " ? " " : harf}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user