feat(ui): Arc kayan vurgulu seçim hapları ve sayı rulosu

- SecimHaplari: tema seçici (kapsül) ve puan türü çipleri (hero + CTA formu);
  seçili vurgu layoutId ile seçenekten seçeneğe kayar.
- SayiRulo: değişen sayılar (listem rozeti, çekmece ve durum satırı sayacı,
  kredi hapları, dilim tablosu sayacı, giriş yeniden gönder sayacı, kaydet
  bandı, üst şerit geri sayımı) rulo gibi döner; @number-flow/react kaldırıldı.
- DESIGN.md §4.21–4.22, components.json @uiarc kaydı.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-06 19:40:30 +03:00
parent a3cb058a3b
commit bc920f6111
18 changed files with 398 additions and 172 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { startTransition, useEffect, useState } from "react";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { CalendarDays, Clock3 } from "lucide-react";
import {
BANT_ARIA,
@@ -131,45 +131,28 @@ export function SiteTopBanner() {
: "Geri sayım yükleniyor"
}
>
<NumberFlowGroup>
{BIRIMLER.map(({ anahtar, etiket }) => (
<div className="flex items-baseline gap-0.5" key={anahtar}>
<dt className="sr-only">{etiket}</dt>
<dd className="font-semibold">
{geriSayim ? (
<NumberFlow
value={geriSayim[anahtar]}
trend={-1}
format={{
minimumIntegerDigits: 2,
useGrouping: false,
}}
transformTiming={{
duration: 280,
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
spinTiming={{
duration: 280,
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
opacityTiming={{
duration: 180,
easing: "ease-out",
}}
willChange={anahtar === "saniye"}
className="inline-block min-w-[2ch] text-right"
aria-hidden
/>
) : (
"--"
)}
<span className="ml-0.5 text-xs font-medium opacity-70">
{etiket}
</span>
</dd>
</div>
))}
</NumberFlowGroup>
{/* Rulo rakam (DESIGN.md §4.21): her birim kendi tekerleğinde
aşağı döner; 00 → 59 sarması da aşağı (yon=-1). */}
{BIRIMLER.map(({ anahtar, etiket }) => (
<div className="flex items-baseline gap-0.5" key={anahtar}>
<dt className="sr-only">{etiket}</dt>
<dd className="font-semibold">
{geriSayim ? (
<SayiRulo
deger={geriSayim[anahtar]}
enAzBasamak={2}
yon={-1}
className="min-w-[2ch] justify-end"
/>
) : (
"--"
)}
<span className="ml-0.5 text-xs font-medium opacity-70">
{etiket}
</span>
</dd>
</div>
))}
</dl>
) : null}
</div>

View File

@@ -3,20 +3,22 @@
import { useSyncExternalStore } from "react";
import { useTheme } from "next-themes";
import { Monitor, Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";
import { SecimHaplari } from "@/components/ui/secim-haplari";
const SECENEKLER = [
{ deger: "light", etiket: "Açık", ikon: Sun },
{ deger: "dark", etiket: "Koyu", ikon: Moon },
{ deger: "system", etiket: "Sistem", ikon: Monitor },
] as const;
type Tema = (typeof SECENEKLER)[number]["deger"];
const aboneOlma = () => () => {};
/**
* Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca
* tarayıcıda bilinir; sunucu HTML'inde hiçbir seçenek işaretli gelmez,
* hydration sonrası işaretlenir (yerleşim oynamaz).
* hydration sonrası işaretlenir (yerleşim oynamaz). Koyu vurgu seçenekten
* seçeneğe kayar (secim-haplari.tsx, kapsül varyantı).
*/
export function TemaSecici({ className }: { className?: string }) {
const { theme, setTheme } = useTheme();
@@ -25,37 +27,19 @@ export function TemaSecici({ className }: { className?: string }) {
() => true,
() => false,
);
const secili = tarayicida
? (SECENEKLER.find((s) => s.deger === theme)?.deger ?? null)
: null;
return (
<div
role="radiogroup"
aria-label="Tema"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1",
className,
)}
>
{SECENEKLER.map(({ deger, etiket, ikon: Ikon }) => {
const secili = tarayicida && theme === deger;
return (
<button
key={deger}
type="button"
role="radio"
aria-checked={secili}
onClick={() => setTheme(deger)}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-sm font-semibold transition-[background-color,color,transform] duration-150 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
secili
? "bg-slate-900 text-slate-50"
: "text-slate-600 hover:text-orange-500",
)}
>
<Ikon className="size-4" aria-hidden />
{etiket}
</button>
);
})}
</div>
<SecimHaplari<Tema>
ariaLabel="Tema"
secenekler={SECENEKLER}
deger={secili}
onChange={setTheme}
varyant="kapsul"
boyut="md"
className={className}
/>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
// Rulo rakam (odometer): sayı değişince her basamak kendi tekerleğinde döner —
// artarken yukarı, azalırken aşağı; 9 → 0 sarar, 10 → 9 geri döner. Basamak
// sayısı değişince (9 → 10) yeni sütun genişliğini yayla açar, kalkan sütun
// kapanır; sütunlar basamak değerine göre anahtarlı olduğundan birler hanesi
// hep birler hanesi kalır.
//
// Kaynak: Arc UI "Number field" (uiarc.dev) rakam rulosu. Oradaki adım
// butonları, sürükleyerek değiştirme ve sınır notu alınmadı — 6 haneli
// sıralama girişine uymuyor; rulo yalnız EKRANDA DEĞİŞEN sayıya takılır
// (liste sayacı gibi). Yay DESIGN.md §6 varsayılanı (sekmesiz, 0,35 s);
// hareketi azalt'ta düz metin. Ekran okuyucu düz metni okur, rulo aria-hidden.
import { useLayoutEffect, useRef, useState } from "react";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
type MotionValue,
type Variants,
} from "motion/react";
import { cn } from "@/lib/utils";
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
const RAKAMLAR = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const BICIMLER = new Map<number, Intl.NumberFormat>();
/** Basamak dolgusu isteyen sayaç (geri sayım "05") binlik ayracı kullanmaz. */
function bicimAl(enAzBasamak: number): Intl.NumberFormat {
let b = BICIMLER.get(enAzBasamak);
if (!b) {
b = new Intl.NumberFormat("tr-TR", {
numberingSystem: "latn",
minimumIntegerDigits: enAzBasamak,
useGrouping: enAzBasamak <= 1,
});
BICIMLER.set(enAzBasamak, b);
}
return b;
}
type Parca =
| { anahtar: string; rakam: number }
| { anahtar: string; metin: string };
/** Biçimlenmiş sayıyı basamak değerine göre anahtarlı sütunlara böler. */
function parcala(deger: number, bicim: Intl.NumberFormat): Parca[] {
const parcalar = bicim.formatToParts(deger);
let basamak = parcalar.reduce(
(n, p) => n + (p.type === "integer" ? p.value.length : 0),
0,
);
return parcalar.flatMap((p, i): Parca[] => {
if (p.type === "integer")
return [...p.value].map((c) => ({ anahtar: `b${--basamak}`, rakam: Number(c) }));
return [
{ anahtar: p.type === "group" ? `g${basamak}` : `${p.type}${i}`, metin: p.value },
];
});
}
// Sütun açılırken değişim yönünde yükselir, kapanırken aynı yoldan gider (§6:
// giriş ve çıkış aynı yoldan).
const yuva: Variants = {
giris: (yon: number) => ({ width: 0, opacity: 0, y: `${yon * 0.3}em` }),
merkez: { width: "auto", opacity: 1, y: 0, transition: YAY },
cikis: (yon: number) => ({
width: 0,
opacity: 0,
y: `${yon * -0.3}em`,
transition: YAY,
}),
};
/** Tekerlekteki bir rakam: tekerleğin konumuna uzaklığı yerini ve görünürlüğünü belirler. */
function Glif({ konum, rakam }: { konum: MotionValue<number>; rakam: number }) {
const uzaklik = (simdiki: number) =>
((((rakam - simdiki) % 10) + 15) % 10) - 5;
const y = useTransform(konum, (c) => `${uzaklik(c) * 1.05}em`);
const opacity = useTransform(konum, (c) =>
Math.max(0, 1 - Math.abs(uzaklik(c)) ** 1.5 * 1.1),
);
const visibility = useTransform(konum, (c) =>
Math.abs(uzaklik(c)) >= 1 ? "hidden" : "visible",
);
return (
<motion.span
className="absolute inset-0 flex items-center justify-center"
style={{ y, opacity, visibility }}
>
{rakam}
</motion.span>
);
}
/** Bir basamağın tekerleği: sayının gittiği yönde döner, yarım turdan uzunsa kısa yolu seçer. */
function Tekerlek({ rakam, yon }: { rakam: number; yon: number }) {
const konum = useMotionValue(rakam);
const durum = useRef({ rakam, hedef: rakam });
useLayoutEffect(() => {
const d = durum.current;
if (d.rakam === rakam) return;
let fark =
yon > 0 ? (rakam - d.rakam + 10) % 10 : -((d.rakam - rakam + 10) % 10);
if (Math.abs(fark) > 5) fark -= Math.sign(fark) * 10;
d.hedef += fark;
d.rakam = rakam;
animate(konum, d.hedef, YAY);
}, [rakam, yon, konum]);
return (
<>
<span className="invisible">0</span>
{RAKAMLAR.map((r) => (
<Glif key={r} konum={konum} rakam={r} />
))}
</>
);
}
/**
* Ekranda değişen bir sayı (liste sayacı gibi). Yazı boyu/rengi/ağırlığı
* kapsayıcıdan gelir; `className` yalnız yerleşim için.
*/
export function SayiRulo({
deger,
className,
enAzBasamak = 1,
yon: sabitYon,
}: {
deger: number;
className?: string;
/** Basamak dolgusu: 2 → "05". Üst çubuk geri sayımı; binlik ayracı kapanır. */
enAzBasamak?: number;
/** Dönüş yönünü sabitler: geri sayımda 00 → 59 yukarı değil aşağı dönsün. */
yon?: 1 | -1;
}) {
const azalt = useReducedMotion();
const [iz, setIz] = useState({ deger, yon: 1 });
if (iz.deger !== deger) setIz({ deger, yon: deger > iz.deger ? 1 : -1 });
const yon =
sabitYon ?? (iz.deger === deger ? iz.yon : deger > iz.deger ? 1 : -1);
const bicim = bicimAl(enAzBasamak);
const metin = bicim.format(deger);
if (azalt) return <span className={className}>{metin}</span>;
return (
<span className={cn("inline-flex", className)}>
<span className="sr-only">{metin}</span>
<span aria-hidden className="inline-flex">
<AnimatePresence initial={false} custom={yon}>
{parcala(deger, bicim).map((p) => (
<motion.span
key={p.anahtar}
custom={yon}
variants={yuva}
initial="giris"
animate="merkez"
exit="cikis"
className="relative inline-block overflow-hidden"
>
{"rakam" in p ? <Tekerlek rakam={p.rakam} yon={yon} /> : p.metin}
</motion.span>
))}
</AnimatePresence>
</span>
</span>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
// Tek seçimli hap grubu (puan türü, tema): seçili vurgu ayrı ayrı yeniden
// boyanmak yerine TEK bir yüzey olarak seçenekten seçeneğe kayar
// (`layoutId`). Göz "hangisi seçili" sorusunu hareketin bittiği yerde okur.
//
// Kaynak: Arc UI "Chip group" / "Floating button group" (uiarc.dev) kayan
// vurgu deseni; sınıf dizisi DESIGN.md hap dili. İki varyant:
// - `ayrik`: her seçenek kendi kenarlıklı hapı, vurgu mavi dolu hap (hero ve
// CTA formundaki puan türü çipleri).
// - `kapsul`: tek kapsül içinde koyu hap (üst çubuk "Giriş yap" diliyle aynı,
// tema seçici). Koyu hap yazısı `text-slate-50` (§2.3).
// Yay §6 varsayılanı; hareketi azalt'ta vurgu kaymaz, yerinde belirir.
import { useId, type ComponentType } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
export interface SecimHapiSecenek<T extends string> {
deger: T;
etiket: string;
ikon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
}
export function SecimHaplari<T extends string>({
secenekler,
deger,
onChange,
ariaLabel,
varyant = "ayrik",
boyut = "sm",
className,
}: {
secenekler: readonly SecimHapiSecenek<T>[];
/** `null`: henüz seçim yok (ör. tema hidrasyondan önce) — vurgu çizilmez. */
deger: T | null;
onChange: (deger: T) => void;
ariaLabel: string;
varyant?: "ayrik" | "kapsul";
boyut?: "sm" | "md";
className?: string;
}) {
const uid = useId();
const azalt = useReducedMotion();
const kapsul = varyant === "kapsul";
const yukseklik = boyut === "md" ? "h-9 px-3 text-sm" : "h-8 px-3 text-xs";
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn(
"flex flex-wrap",
kapsul
? "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1"
: "gap-1.5",
className,
)}
>
{secenekler.map(({ deger: d, etiket, ikon: Ikon }) => {
const secili = deger === d;
return (
<button
key={d}
type="button"
role="radio"
aria-checked={secili}
onClick={() => onChange(d)}
className={cn(
"relative inline-flex cursor-pointer items-center gap-1.5 rounded-full font-semibold outline-none transition-[color,border-color,background-color,transform] duration-200 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
yukseklik,
kapsul
? secili
? "text-slate-50"
: "text-slate-600 hover:text-orange-500"
: secili
? "border border-transparent text-white"
: "border border-slate-200 bg-card text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
>
{secili ? (
<motion.span
layoutId={`${uid}-vurgu`}
initial={false}
transition={azalt ? { duration: 0 } : YAY}
className={cn(
"absolute inset-0 rounded-full",
kapsul ? "bg-slate-900" : "bg-primary",
)}
aria-hidden
/>
) : null}
{Ikon ? (
<Ikon className="relative size-4 shrink-0" aria-hidden />
) : null}
<span className="relative">{etiket}</span>
</button>
);
})}
</div>
);
}