@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.
- package/dist/index.d.mts +102 -1
- package/dist/index.d.ts +102 -1
- package/dist/index.global.js +19 -41
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +301 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +290 -1
- package/dist/index.mjs.map +1 -1
- package/dist/lib/theme.global.js +6 -30
- package/dist/lib/theme.global.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ui/__tests__/kbd.test.tsx +86 -0
- package/src/components/ui/__tests__/rating.test.tsx +218 -0
- package/src/components/ui/__tests__/spinner.test.tsx +141 -0
- package/src/components/ui/index.ts +24 -0
- package/src/components/ui/kbd.tsx +66 -0
- package/src/components/ui/rating.tsx +253 -0
- package/src/components/ui/spinner.tsx +112 -0
|
@@ -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 };
|