@moontra/moonui 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ "use client"
2
+
3
+ import * as React from "react";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { Star } from "lucide-react";
6
+ import { cn } from "../../lib/utils";
7
+
8
+ /**
9
+ * Premium Rating Component
10
+ *
11
+ * Standalone yıldız-değerlendirme bileşeni (input + display).
12
+ * Kontrollü/kontrolsüz kullanım, yarım-yıldız hassasiyeti, salt-gösterim modu
13
+ * ve tam klavye erişilebilirliği (radiogroup) sunar. Dark/light modda uyumludur.
14
+ */
15
+
16
+ const ratingVariants = cva(
17
+ [
18
+ "moonui-theme inline-flex items-center rounded-md outline-none",
19
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
20
+ ],
21
+ {
22
+ variants: {
23
+ size: {
24
+ sm: "gap-0.5",
25
+ md: "gap-1",
26
+ lg: "gap-1.5",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ size: "md",
31
+ },
32
+ }
33
+ );
34
+
35
+ // Boyuta göre ikon ölçüsü (yarım-yıldız maskesinin hizalanması için sabit px gerekir)
36
+ const iconSizeMap: Record<NonNullable<VariantProps<typeof ratingVariants>["size"]>, string> = {
37
+ sm: "h-4 w-4",
38
+ md: "h-5 w-5",
39
+ lg: "h-6 w-6",
40
+ };
41
+
42
+ export interface RatingProps
43
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">,
44
+ VariantProps<typeof ratingVariants> {
45
+ /** Kontrollü değer (0..max) */
46
+ value?: number;
47
+ /** Kontrolsüz başlangıç değeri */
48
+ defaultValue?: number;
49
+ /** Değer değiştiğinde çağrılır */
50
+ onValueChange?: (value: number) => void;
51
+ /** Yıldız sayısı (varsayılan 5) */
52
+ max?: number;
53
+ /** Salt-gösterim: etkileşim yok, aria-readonly işaretlenir */
54
+ readOnly?: boolean;
55
+ /** Yarım-yıldız desteği (varsayılan "full") */
56
+ precision?: "full" | "half";
57
+ /** Özel ikon (varsayılan lucide Star) */
58
+ icon?: React.ReactNode;
59
+ }
60
+
61
+ /**
62
+ * Premium Rating Component
63
+ *
64
+ * @param props.value - Kontrollü değer
65
+ * @param props.defaultValue - Kontrolsüz başlangıç değeri
66
+ * @param props.onValueChange - Değer değişim geri çağrımı
67
+ * @param props.max - Yıldız sayısı (varsayılan 5)
68
+ * @param props.readOnly - Salt-gösterim modu
69
+ * @param props.precision - "full" | "half" hassasiyet
70
+ * @param props.icon - Özel ikon
71
+ * @param props.size - Boyut (sm | md | lg)
72
+ */
73
+ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
74
+ (
75
+ {
76
+ className,
77
+ value,
78
+ defaultValue,
79
+ onValueChange,
80
+ max = 5,
81
+ readOnly = false,
82
+ precision = "full",
83
+ icon,
84
+ size,
85
+ "aria-label": ariaLabel = "Rating",
86
+ ...props
87
+ },
88
+ ref
89
+ ) => {
90
+ const resolvedSize = size ?? "md";
91
+ const iconSizeClass = iconSizeMap[resolvedSize];
92
+ const step = precision === "half" ? 0.5 : 1;
93
+
94
+ const isControlled = value !== undefined;
95
+ const [internalValue, setInternalValue] = React.useState<number>(defaultValue ?? 0);
96
+ const currentValue = isControlled ? value ?? 0 : internalValue;
97
+
98
+ // Hover önizlemesi (geçici doldurma)
99
+ const [hoverValue, setHoverValue] = React.useState<number | null>(null);
100
+ const displayValue = hoverValue !== null ? hoverValue : currentValue;
101
+
102
+ // Değeri işle: kontrolsüzde iç state'i güncelle, her durumda geri çağrımı tetikle
103
+ const commit = React.useCallback(
104
+ (next: number) => {
105
+ if (readOnly) return;
106
+ if (!isControlled) setInternalValue(next);
107
+ onValueChange?.(next);
108
+ },
109
+ [readOnly, isControlled, onValueChange]
110
+ );
111
+
112
+ // Klavye: ok-tuşları değeri step kadar artır/azalt, Home/End min/max
113
+ const handleKeyDown = React.useCallback(
114
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
115
+ if (readOnly) return;
116
+ let next: number | null = null;
117
+ switch (event.key) {
118
+ case "ArrowRight":
119
+ case "ArrowUp":
120
+ next = Math.min(max, currentValue + step);
121
+ break;
122
+ case "ArrowLeft":
123
+ case "ArrowDown":
124
+ next = Math.max(step, currentValue - step);
125
+ break;
126
+ case "Home":
127
+ next = step;
128
+ break;
129
+ case "End":
130
+ next = max;
131
+ break;
132
+ default:
133
+ return;
134
+ }
135
+ event.preventDefault();
136
+ commit(next);
137
+ },
138
+ [readOnly, max, currentValue, step, commit]
139
+ );
140
+
141
+ // İkonu doldurmalı/boş olarak üret (özel ikon desteklenir)
142
+ const renderIcon = (filled: boolean): React.ReactNode => {
143
+ if (React.isValidElement(icon)) {
144
+ const element = icon as React.ReactElement<{ className?: string }>;
145
+ return React.cloneElement(element, {
146
+ className: cn(
147
+ iconSizeClass,
148
+ "shrink-0",
149
+ filled && "fill-current",
150
+ element.props?.className
151
+ ),
152
+ });
153
+ }
154
+ return (
155
+ <Star
156
+ className={cn(iconSizeClass, "shrink-0", filled && "fill-current")}
157
+ aria-hidden="true"
158
+ />
159
+ );
160
+ };
161
+
162
+ const stars = Array.from({ length: max }, (_, index) => index + 1);
163
+
164
+ return (
165
+ <div
166
+ ref={ref}
167
+ role="radiogroup"
168
+ aria-label={ariaLabel}
169
+ aria-orientation="horizontal"
170
+ aria-readonly={readOnly || undefined}
171
+ tabIndex={readOnly ? undefined : 0}
172
+ onKeyDown={handleKeyDown}
173
+ onMouseLeave={() => setHoverValue(null)}
174
+ className={cn(ratingVariants({ size }), className)}
175
+ {...props}
176
+ >
177
+ {stars.map((starValue) => {
178
+ // Bu yıldızın doluluk oranı (0..1) — yarım yıldız için ondalık olur
179
+ const fraction = Math.max(0, Math.min(1, displayValue - (starValue - 1)));
180
+ const halfValue = starValue - 0.5;
181
+
182
+ return (
183
+ <span
184
+ key={starValue}
185
+ data-slot="rating-star"
186
+ className={cn(
187
+ "relative inline-flex shrink-0",
188
+ !readOnly && "cursor-pointer"
189
+ )}
190
+ >
191
+ {/* Boş katman (görsel taban) */}
192
+ <span aria-hidden="true" className="inline-flex text-muted-foreground/30">
193
+ {renderIcon(false)}
194
+ </span>
195
+
196
+ {/* Dolu katman — overflow-hidden maske ile kısmi doldurma */}
197
+ <span
198
+ aria-hidden="true"
199
+ className="pointer-events-none absolute left-0 top-0 h-full overflow-hidden text-amber-400"
200
+ style={{ width: `${fraction * 100}%` }}
201
+ >
202
+ {renderIcon(true)}
203
+ </span>
204
+
205
+ {/* Etkileşim katmanı (salt-gösterimde yok) */}
206
+ {!readOnly && precision === "half" && (
207
+ <>
208
+ <button
209
+ type="button"
210
+ role="radio"
211
+ aria-checked={currentValue === halfValue}
212
+ aria-label={`${halfValue} stars`}
213
+ tabIndex={-1}
214
+ onClick={() => commit(halfValue)}
215
+ onMouseEnter={() => setHoverValue(halfValue)}
216
+ className="absolute inset-y-0 left-0 z-10 w-1/2 cursor-pointer bg-transparent"
217
+ />
218
+ <button
219
+ type="button"
220
+ role="radio"
221
+ aria-checked={currentValue === starValue}
222
+ aria-label={`${starValue} stars`}
223
+ tabIndex={-1}
224
+ onClick={() => commit(starValue)}
225
+ onMouseEnter={() => setHoverValue(starValue)}
226
+ className="absolute inset-y-0 right-0 z-10 w-1/2 cursor-pointer bg-transparent"
227
+ />
228
+ </>
229
+ )}
230
+
231
+ {!readOnly && precision === "full" && (
232
+ <button
233
+ type="button"
234
+ role="radio"
235
+ aria-checked={currentValue === starValue}
236
+ aria-label={`${starValue} ${starValue === 1 ? "star" : "stars"}`}
237
+ tabIndex={-1}
238
+ onClick={() => commit(starValue)}
239
+ onMouseEnter={() => setHoverValue(starValue)}
240
+ className="absolute inset-0 z-10 cursor-pointer bg-transparent"
241
+ />
242
+ )}
243
+ </span>
244
+ );
245
+ })}
246
+ </div>
247
+ );
248
+ }
249
+ );
250
+
251
+ Rating.displayName = "Rating";
252
+
253
+ export { Rating, ratingVariants };
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ /**
8
+ * Spinner Component
9
+ *
10
+ * Bağımsız (standalone) dönen yükleme göstergesi. Asenkron işlemler
11
+ * sırasında kullanıcıya süregelen bir aktivite olduğunu bildirmek için
12
+ * kullanılır.
13
+ *
14
+ * Erişilebilirlik: `role="status"` + `aria-live="polite"` ile ekran
15
+ * okuyuculara duyurulur; görsel olarak gizli (`sr-only`) `label` metni
16
+ * seslendirilir. `prefers-reduced-motion` açıkken animasyon
17
+ * `motion-reduce:animate-none` ile durdurulur.
18
+ *
19
+ * Renk `currentColor` üzerinden miras alınır; `text-primary`, `text-muted-foreground`
20
+ * gibi metin renk yardımcılarıyla renklendirilir (hardcoded renk yoktur).
21
+ */
22
+
23
+ // Dönen halka (default) varyantının temel stilleri + boyut eşlemesi
24
+ const spinnerVariants = cva(
25
+ [
26
+ "inline-block shrink-0 rounded-full",
27
+ "border-current border-t-transparent",
28
+ "animate-spin motion-reduce:animate-none",
29
+ ],
30
+ {
31
+ variants: {
32
+ size: {
33
+ sm: "h-4 w-4 border-2", // 16px
34
+ md: "h-6 w-6 border-2", // 24px
35
+ lg: "h-8 w-8 border-[3px]", // 32px
36
+ xl: "h-10 w-10 border-4", // 40px
37
+ },
38
+ },
39
+ defaultVariants: {
40
+ size: "md",
41
+ },
42
+ }
43
+ );
44
+
45
+ // Dots varyantı için nokta boyutu eşlemesi (renk `currentColor` ile gelir)
46
+ const dotSizeMap = {
47
+ sm: "h-1 w-1",
48
+ md: "h-1.5 w-1.5",
49
+ lg: "h-2 w-2",
50
+ xl: "h-2.5 w-2.5",
51
+ } as const;
52
+
53
+ export interface SpinnerProps
54
+ extends React.HTMLAttributes<HTMLDivElement>,
55
+ VariantProps<typeof spinnerVariants> {
56
+ /**
57
+ * Görsel stil: dönen halka (`default`) veya zıplayan noktalar (`dots`)
58
+ */
59
+ variant?: "default" | "dots";
60
+ /**
61
+ * Ekran okuyucular için erişilebilir metin (görsel olarak gizli)
62
+ * @default "Loading"
63
+ */
64
+ label?: string;
65
+ }
66
+
67
+ /**
68
+ * Spinner — dönen yükleme göstergesi
69
+ *
70
+ * @param props.size - Gösterge boyutu (`sm` | `md` | `lg` | `xl`)
71
+ * @param props.variant - Görsel stil (`default` halka | `dots` noktalar)
72
+ * @param props.label - Ekran okuyucular için erişilebilir metin (varsayılan "Loading")
73
+ */
74
+ const Spinner = React.forwardRef<HTMLDivElement, SpinnerProps>(
75
+ ({ className, size, variant = "default", label = "Loading", ...props }, ref) => {
76
+ return (
77
+ <div
78
+ ref={ref}
79
+ role="status"
80
+ aria-live="polite"
81
+ className={cn(
82
+ "moonui-theme inline-flex items-center justify-center",
83
+ className
84
+ )}
85
+ {...props}
86
+ >
87
+ {variant === "dots" ? (
88
+ <span className="inline-flex items-center gap-1" aria-hidden="true">
89
+ {[0, 1, 2].map((i) => (
90
+ <span
91
+ key={i}
92
+ className={cn(
93
+ "inline-block rounded-full bg-current",
94
+ "animate-bounce motion-reduce:animate-none",
95
+ dotSizeMap[size ?? "md"]
96
+ )}
97
+ style={{ animationDelay: `${i * 0.15}s` }}
98
+ />
99
+ ))}
100
+ </span>
101
+ ) : (
102
+ <span className={cn(spinnerVariants({ size }))} aria-hidden="true" />
103
+ )}
104
+ {/* Görsel olarak gizli erişilebilir metin */}
105
+ <span className="sr-only">{label}</span>
106
+ </div>
107
+ );
108
+ }
109
+ );
110
+ Spinner.displayName = "Spinner";
111
+
112
+ export { Spinner, spinnerVariants };