@moontra/moonui 3.4.0 → 4.1.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.
Files changed (34) hide show
  1. package/dist/index.d.mts +34 -19
  2. package/dist/index.d.ts +34 -19
  3. package/dist/index.global.js +355 -42
  4. package/dist/index.global.js.map +1 -1
  5. package/dist/index.js +539 -387
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +505 -354
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/lib/theme.global.js +14 -1
  10. package/dist/lib/theme.global.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/components/ui/__tests__/alert.test.tsx +5 -5
  13. package/src/components/ui/__tests__/avatar.test.tsx +12 -12
  14. package/src/components/ui/__tests__/badge.test.tsx +17 -1
  15. package/src/components/ui/__tests__/label.test.tsx +3 -2
  16. package/src/components/ui/__tests__/select.test.tsx +24 -2
  17. package/src/components/ui/__tests__/switch.test.tsx +36 -3
  18. package/src/components/ui/__tests__/tags-input.test.tsx +163 -0
  19. package/src/components/ui/alert.tsx +5 -2
  20. package/src/components/ui/aspect-ratio.tsx +9 -3
  21. package/src/components/ui/avatar.tsx +18 -6
  22. package/src/components/ui/badge.tsx +18 -11
  23. package/src/components/ui/button.tsx +3 -0
  24. package/src/components/ui/date-picker.tsx +38 -19
  25. package/src/components/ui/file-upload.tsx +4 -0
  26. package/src/components/ui/index.ts +22 -0
  27. package/src/components/ui/input-otp.tsx +5 -0
  28. package/src/components/ui/input.tsx +7 -2
  29. package/src/components/ui/label.tsx +6 -3
  30. package/src/components/ui/select.tsx +59 -23
  31. package/src/components/ui/simple-editor.tsx +18 -3
  32. package/src/components/ui/switch.tsx +108 -51
  33. package/src/components/ui/tags-input.tsx +86 -14
  34. package/src/components/ui/textarea.tsx +8 -4
@@ -157,6 +157,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
157
157
  placeholder,
158
158
  ...props
159
159
  }, ref) => {
160
+ // Mesaj id'leri `props.id` yoksa "-error"/"-success" gibi SABİT string'e
161
+ // düşüyordu; aynı sayfada iki id'siz Input olduğunda DOM'da yinelenen id
162
+ // oluşuyor ve aria-describedby yanlış elemanı işaret ediyordu.
163
+ const reactId = React.useId();
164
+ const fieldId = props.id || reactId;
160
165
  // Floating label state management
161
166
  const [isFocused, setIsFocused] = React.useState(false);
162
167
  const [internalValue, setInternalValue] = React.useState(value || defaultValue || "");
@@ -242,7 +247,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
242
247
  data-error={!!error ? "" : undefined}
243
248
  data-success={!!success ? "" : undefined}
244
249
  aria-invalid={!!error || !!isError || undefined}
245
- aria-describedby={error ? `${props.id || ''}-error` : success ? `${props.id || ''}-success` : undefined}
250
+ aria-describedby={error ? `${fieldId}-error` : success ? `${fieldId}-success` : undefined}
246
251
  value={value}
247
252
  defaultValue={defaultValue}
248
253
  onChange={handleChange}
@@ -280,7 +285,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
280
285
  messageType === "normal" && "text-muted-foreground",
281
286
  messageClassName
282
287
  )}
283
- id={error ? `${props.id || ''}-error` : success ? `${props.id || ''}-success` : undefined}
288
+ id={error ? `${fieldId}-error` : success ? `${fieldId}-success` : undefined}
284
289
  >
285
290
  {error || success || ""}
286
291
  </p>
@@ -7,13 +7,16 @@ import { cva, type VariantProps } from "class-variance-authority"
7
7
  import { cn } from "../../lib/utils"
8
8
 
9
9
  const labelVariants = cva(
10
- "text-sm font-medium leading-none text-gray-900 dark:text-gray-200 peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:peer-disabled:opacity-60 transition-colors duration-200"
10
+ "text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:peer-disabled:opacity-60 transition-colors duration-200"
11
11
  )
12
12
 
13
+ export interface LabelProps
14
+ extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>,
15
+ VariantProps<typeof labelVariants> {}
16
+
13
17
  const Label = React.forwardRef<
14
18
  React.ElementRef<typeof LabelPrimitive.Root>,
15
- React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
16
- VariantProps<typeof labelVariants>
19
+ LabelProps
17
20
  >(({ className, ...props }, ref) => (
18
21
  <LabelPrimitive.Root
19
22
  ref={ref}
@@ -3,6 +3,7 @@
3
3
  import * as React from "react"
4
4
  import * as SelectPrimitive from "@radix-ui/react-select"
5
5
  import { Check, ChevronDown, ChevronUp, Loader2 } from "lucide-react"
6
+ import { cva } from "class-variance-authority"
6
7
 
7
8
  import { cn } from "../../lib/utils"
8
9
 
@@ -24,7 +25,51 @@ const SelectValue = SelectPrimitive.Value
24
25
  type SelectTriggerVariant = "standard" | "outline" | "ghost" | "underline";
25
26
  type SelectTriggerSize = "sm" | "md" | "lg";
26
27
 
27
- interface SelectTriggerProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> {
28
+ /**
29
+ * Trigger varyantları `cva()` ile yönetilir (kütüphane geneli desen). Önceden
30
+ * `variant === "x" && "..."` manuel zincirlemesi kullanılıyor ve tüm renkler ham
31
+ * `gray-*` paletiyle yazılıyordu — tema/karanlık mod bu değerleri etkileyemiyordu.
32
+ * Artık semantik token'lara bağlı (`border-input`, `bg-background`, `bg-muted`…).
33
+ */
34
+ const selectTriggerVariants = cva(
35
+ [
36
+ "flex w-full items-center justify-between gap-1 rounded-md transition-all duration-200",
37
+ "disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
38
+ "focus-visible:outline-none text-foreground",
39
+ ],
40
+ {
41
+ variants: {
42
+ variant: {
43
+ standard:
44
+ "border border-input bg-background hover:border-ring focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary",
45
+ outline:
46
+ "border border-input bg-transparent hover:border-ring focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary",
47
+ ghost:
48
+ "border-none bg-muted hover:bg-muted/80 focus-visible:ring-2 focus-visible:ring-primary/30",
49
+ underline:
50
+ "border-t-0 border-l-0 border-r-0 border-b border-input rounded-none px-0 bg-transparent hover:border-ring focus-visible:ring-0 focus-visible:border-b-2 focus-visible:border-primary",
51
+ },
52
+ size: {
53
+ sm: "h-8 text-xs px-2",
54
+ md: "h-10 text-sm px-3",
55
+ lg: "h-12 text-base px-4",
56
+ },
57
+ state: {
58
+ none: "",
59
+ error: "border-error focus-visible:ring-error/30 focus-visible:border-error",
60
+ success:
61
+ "border-success focus-visible:ring-success/30 focus-visible:border-success",
62
+ },
63
+ },
64
+ defaultVariants: {
65
+ variant: "standard",
66
+ size: "md",
67
+ state: "none",
68
+ },
69
+ }
70
+ )
71
+
72
+ export interface SelectTriggerProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> {
28
73
  /** Visual variant */
29
74
  variant?: SelectTriggerVariant;
30
75
  /** Size */
@@ -44,45 +89,36 @@ interface SelectTriggerProps extends React.ComponentPropsWithoutRef<typeof Selec
44
89
  const SelectTrigger = React.forwardRef<
45
90
  React.ElementRef<typeof SelectPrimitive.Trigger>,
46
91
  SelectTriggerProps
47
- >(({ className, children, variant = "standard", size = "md", error, success, loading, leftIcon, rightIcon, ...props }, ref) => (
92
+ >(({ className, children, variant, size, error, success, loading, leftIcon, rightIcon, ...props }, ref) => (
48
93
  <SelectPrimitive.Trigger
49
94
  ref={ref}
50
95
  className={cn(
51
- /* Base styles */
52
- "flex w-full items-center justify-between gap-1 rounded-md transition-all duration-200",
53
- "disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
54
- "focus-visible:outline-none",
55
- /* Error state */
56
- error && "border-error focus-visible:ring-error/30 focus-visible:border-error",
57
- /* Success state */
58
- success && "border-success focus-visible:ring-success/30 focus-visible:border-success",
59
- /* Size variants */
60
- size === "sm" && "h-8 text-xs px-2",
61
- size === "md" && "h-10 text-sm px-3",
62
- size === "lg" && "h-12 text-base px-4",
63
- /* Visual variants */
64
- variant === "standard" && "border border-gray-300 dark:border-gray-700 bg-background dark:bg-gray-800/80 dark:shadow-inner dark:shadow-gray-950/10 dark:text-gray-200 hover:border-gray-400 dark:hover:border-gray-600 focus-visible:ring-2 focus-visible:ring-primary/30 dark:focus-visible:ring-primary/20 focus-visible:border-primary dark:focus-visible:border-primary/80",
65
- variant === "outline" && "border border-gray-300 dark:border-gray-700 bg-transparent dark:text-gray-200 hover:border-gray-400 dark:hover:border-gray-600 focus-visible:ring-2 focus-visible:ring-primary/30 dark:focus-visible:ring-primary/20 focus-visible:border-primary dark:focus-visible:border-primary/80",
66
- variant === "ghost" && "border-none bg-gray-100 dark:bg-gray-800 dark:shadow-inner dark:shadow-gray-950/10 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 focus-visible:ring-2 focus-visible:ring-primary/30 dark:focus-visible:ring-primary/20",
67
- variant === "underline" && "border-t-0 border-l-0 border-r-0 border-b border-gray-300 dark:border-gray-600 rounded-none px-0 dark:text-gray-200 dark:bg-transparent hover:border-gray-400 dark:hover:border-gray-500 focus-visible:ring-0 focus-visible:border-b-2 focus-visible:border-primary dark:focus-visible:border-primary/80",
96
+ selectTriggerVariants({
97
+ variant,
98
+ size,
99
+ state: error ? "error" : success ? "success" : "none",
100
+ }),
68
101
  className
69
102
  )}
70
103
  {...props}
71
104
  disabled={props.disabled || loading}
105
+ // `error` görsel olarak işaretleniyordu ama yardımcı teknolojiye hiç
106
+ // duyurulmuyordu; Input/Textarea aynı kavramda `aria-invalid` set ediyor.
107
+ aria-invalid={error ? true : undefined}
72
108
  data-error={error ? true : undefined}
73
109
  data-success={success ? true : undefined}
74
110
  data-loading={loading ? true : undefined}
75
111
  >
76
112
  <div className="flex items-center flex-1 gap-2">
77
113
  {leftIcon && (
78
- <span className="flex items-center justify-center text-gray-500 dark:text-gray-400">
114
+ <span className="flex items-center justify-center text-muted-foreground">
79
115
  {leftIcon}
80
116
  </span>
81
117
  )}
82
118
  {loading ? (
83
119
  <div className="flex items-center gap-2">
84
- <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground dark:text-gray-400" />
85
- <span className="text-muted-foreground dark:text-gray-400">Loading...</span>
120
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
121
+ <span className="text-muted-foreground">Loading...</span>
86
122
  </div>
87
123
  ) : children}
88
124
  </div>
@@ -198,7 +234,7 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName
198
234
  type SelectItemVariant = "default" | "subtle" | "destructive" | "success" | "warning";
199
235
  type SelectItemSize = "sm" | "md" | "lg";
200
236
 
201
- interface SelectItemProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
237
+ export interface SelectItemProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
202
238
  /** Visual variant */
203
239
  variant?: SelectItemVariant;
204
240
  /** Size */
@@ -59,6 +59,10 @@ const ToolbarButton = ({ icon, tooltip, active, onClick, disabled }: ToolbarButt
59
59
  pressed={active}
60
60
  onPressedChange={() => onClick()}
61
61
  disabled={disabled}
62
+ // Butonun tek içeriği bir ikon; erişilebilir ad olmadan ekran okuyucu
63
+ // yalnızca "button" der. `tooltip` zaten insan-okur etiketi taşıyor.
64
+ // (Tooltip yalnızca fare/odak ile görünür, erişilebilir ad SAĞLAMAZ.)
65
+ aria-label={tooltip}
62
66
  className="h-8 w-8 p-0"
63
67
  >
64
68
  {icon}
@@ -82,7 +86,10 @@ const ColorPicker = ({ onColorSelect }: { onColorSelect: (color: string) => void
82
86
  {colors.map(color => (
83
87
  <button
84
88
  key={color}
85
- className="w-6 h-6 rounded border-2 border-gray-200 hover:border-gray-400"
89
+ type="button"
90
+ // Swatch'ın tek içeriği arka plan rengi — erişilebilir ad şart.
91
+ aria-label={`Select color ${color}`}
92
+ className="w-6 h-6 rounded border-2 border-border hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
86
93
  style={{ backgroundColor: color }}
87
94
  onClick={() => onColorSelect(color)}
88
95
  />
@@ -589,10 +596,18 @@ export const SimpleEditor = React.forwardRef<HTMLDivElement, SimpleEditorProps>(
589
596
  ref={editorRef}
590
597
  contentEditable={!disabled}
591
598
  suppressContentEditableWarning
599
+ // Düz `div[contentEditable]` yardımcı teknolojiye bir form alanı
600
+ // olarak görünmüyordu: rolü yok, çok satırlı olduğu bilinmiyor ve
601
+ // erişilebilir adı yok.
602
+ role="textbox"
603
+ aria-multiline="true"
604
+ aria-label={placeholder}
605
+ aria-disabled={disabled || undefined}
592
606
  className={cn(
593
607
  "p-4 outline-none prose prose-sm dark:prose-invert max-w-none overflow-y-auto",
594
- "bg-slate-50 dark:bg-zinc-900/95",
595
- "text-gray-900 dark:text-gray-100",
608
+ // Token'a bağlandı (önce `bg-slate-50 dark:bg-zinc-900/95` ve
609
+ // `text-gray-900 dark:text-gray-100` hardcode'luydu).
610
+ "bg-background text-foreground",
596
611
  "focus:ring-0",
597
612
  "[&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mb-4",
598
613
  "[&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mb-3",
@@ -2,11 +2,68 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import * as SwitchPrimitives from "@radix-ui/react-switch"
5
+ import { cva } from "class-variance-authority"
5
6
 
6
7
  import { cn } from "../../lib/utils"
7
8
 
8
9
  type SwitchSize = "sm" | "md" | "lg";
9
- type SwitchVariant = "primary" | "success" | "warning" | "danger" | "secondary";
10
+ type SwitchVariant = "primary" | "success" | "warning" | "destructive" | "secondary";
11
+
12
+ /**
13
+ * Boyut ve renk varyantları `cva()` ile yönetilir (kütüphane geneli desen).
14
+ * Önceden `size === "x" && "..."` biçiminde manuel zincirleme kullanılıyordu;
15
+ * `defaultVariants` yoktu ve varsayılanlar destructuring'de gizliydi.
16
+ */
17
+ const switchVariants = cva(
18
+ [
19
+ "peer relative inline-flex shrink-0 cursor-pointer items-center rounded-full",
20
+ "border-2 border-transparent transition-colors",
21
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
22
+ "focus-visible:ring-offset-2 focus-visible:ring-offset-background",
23
+ "disabled:cursor-not-allowed disabled:opacity-50",
24
+ // Kapalı durum token'a bağlı; `--input` karanlık modda kendi değerini taşır.
25
+ // (Önceden ayrıca `dark:data-[state=unchecked]:bg-gray-700/70` hardcode'u
26
+ // vardı ve token'ı eziyordu — kaldırıldı.)
27
+ "data-[state=unchecked]:bg-input",
28
+ "dark:focus-visible:ring-primary/40",
29
+ ],
30
+ {
31
+ variants: {
32
+ size: {
33
+ sm: "h-4 w-8",
34
+ md: "h-6 w-11",
35
+ lg: "h-7 w-14",
36
+ },
37
+ variant: {
38
+ primary: "data-[state=checked]:bg-primary",
39
+ success: "data-[state=checked]:bg-success",
40
+ warning: "data-[state=checked]:bg-warning",
41
+ destructive: "data-[state=checked]:bg-error",
42
+ secondary: "data-[state=checked]:bg-accent",
43
+ },
44
+ },
45
+ defaultVariants: {
46
+ size: "md",
47
+ variant: "primary",
48
+ },
49
+ }
50
+ )
51
+
52
+ const switchThumbVariants = cva(
53
+ "pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform",
54
+ {
55
+ variants: {
56
+ size: {
57
+ sm: "h-3 w-3 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
58
+ md: "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
59
+ lg: "h-6 w-6 data-[state=checked]:translate-x-7 data-[state=unchecked]:translate-x-0",
60
+ },
61
+ },
62
+ defaultVariants: {
63
+ size: "md",
64
+ },
65
+ }
66
+ )
10
67
 
11
68
  export interface SwitchProps extends React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> {
12
69
  /** Switch boyutu */
@@ -26,58 +83,58 @@ export interface SwitchProps extends React.ComponentPropsWithoutRef<typeof Switc
26
83
  const Switch = React.forwardRef<
27
84
  React.ElementRef<typeof SwitchPrimitives.Root>,
28
85
  SwitchProps
29
- >(({ className, size = "md", variant = "primary", loading, leftIcon, rightIcon, description, ...props }, ref) => (
30
- <div className="moonui-theme inline-flex items-center gap-2">
31
- {leftIcon && <span className="text-muted-foreground">{leftIcon}</span>}
32
- <SwitchPrimitives.Root
33
- className={cn(
34
- "peer relative inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 dark:data-[state=unchecked]:bg-gray-700/70 dark:focus-visible:ring-primary/40 dark:focus-visible:ring-offset-gray-950",
35
-
36
- // Boyutlar
37
- size === "sm" && "h-4 w-8",
38
- size === "md" && "h-6 w-11",
39
- size === "lg" && "h-7 w-14",
40
-
41
- // Varyantlar (checked durumunda)
42
- variant === "primary" && "data-[state=checked]:bg-primary",
43
- variant === "success" && "data-[state=checked]:bg-success",
44
- variant === "warning" && "data-[state=checked]:bg-warning",
45
- variant === "danger" && "data-[state=checked]:bg-error",
46
- variant === "secondary" && "data-[state=checked]:bg-accent",
47
-
48
- // Unchecked durumu
49
- "data-[state=unchecked]:bg-input",
50
-
51
- // Loading durumu
52
- loading && "opacity-80 cursor-wait",
53
-
54
- className
55
- )}
56
- disabled={loading || props.disabled}
57
- {...props}
58
- ref={ref}
59
- >
60
- {loading ? (
61
- <div className="absolute inset-0 flex items-center justify-center">
62
- <div className="h-3 w-3 animate-spin rounded-full border-2 border-gray-300 border-t-primary"></div>
63
- </div>
64
- ) : null}
65
-
66
- <SwitchPrimitives.Thumb
86
+ >(({
87
+ className,
88
+ size,
89
+ variant,
90
+ loading,
91
+ leftIcon,
92
+ rightIcon,
93
+ description,
94
+ "aria-describedby": ariaDescribedBy,
95
+ ...props
96
+ }, ref) => {
97
+ // `description` görsel olarak render ediliyordu ama switch ile hiçbir
98
+ // programatik bağı yoktu — ekran okuyucu açıklamayı okuyamıyordu.
99
+ // Stabil bir id üretilip `aria-describedby` ile bağlanır; tüketicinin
100
+ // kendi verdiği `aria-describedby` korunur.
101
+ const reactId = React.useId()
102
+ const descriptionId = description ? `${reactId}-description` : undefined
103
+ const describedBy =
104
+ [ariaDescribedBy, descriptionId].filter(Boolean).join(" ") || undefined
105
+
106
+ return (
107
+ <div className="moonui-theme inline-flex items-center gap-2">
108
+ {leftIcon && <span className="text-muted-foreground">{leftIcon}</span>}
109
+ <SwitchPrimitives.Root
67
110
  className={cn(
68
- "pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform",
69
- // Boyuta göre thumb boyutları
70
- size === "sm" && "h-3 w-3 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
71
- size === "md" && "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
72
- size === "lg" && "h-6 w-6 data-[state=checked]:translate-x-7 data-[state=unchecked]:translate-x-0",
111
+ switchVariants({ size, variant }),
112
+ loading && "opacity-80 cursor-wait",
113
+ className
73
114
  )}
74
- />
75
- </SwitchPrimitives.Root>
76
- {rightIcon && <span className="text-muted-foreground">{rightIcon}</span>}
77
- {description && <span className="text-sm text-muted-foreground">{description}</span>}
78
- </div>
79
- ))
115
+ disabled={loading || props.disabled}
116
+ aria-describedby={describedBy}
117
+ {...props}
118
+ ref={ref}
119
+ >
120
+ {loading ? (
121
+ <div className="absolute inset-0 flex items-center justify-center">
122
+ <div className="h-3 w-3 animate-spin rounded-full border-2 border-muted border-t-primary" />
123
+ </div>
124
+ ) : null}
125
+
126
+ <SwitchPrimitives.Thumb className={cn(switchThumbVariants({ size }))} />
127
+ </SwitchPrimitives.Root>
128
+ {rightIcon && <span className="text-muted-foreground">{rightIcon}</span>}
129
+ {description && (
130
+ <span id={descriptionId} className="text-sm text-muted-foreground">
131
+ {description}
132
+ </span>
133
+ )}
134
+ </div>
135
+ )
136
+ })
80
137
  Switch.displayName = SwitchPrimitives.Root.displayName
81
138
 
82
- export { Switch }
139
+ export { Switch, switchVariants }
83
140
  export type { SwitchSize, SwitchVariant }
@@ -34,32 +34,92 @@ const TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(
34
34
  const [inputValue, setInputValue] = React.useState("");
35
35
  const [error, setError] = React.useState("");
36
36
 
37
+ // Klavye navigasyonu için hem dışa verilen ref hem iç ref gerekiyor:
38
+ // etiketler arasında gezinirken odağı input'a geri döndürebilmeliyiz.
39
+ const innerInputRef = React.useRef<HTMLInputElement>(null);
40
+ React.useImperativeHandle(ref, () => innerInputRef.current as HTMLInputElement);
41
+
42
+ const tagButtonRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
43
+ const errorId = React.useId();
44
+
45
+ const focusTag = (index: number) => {
46
+ if (value.length === 0) return;
47
+ const clamped = Math.max(0, Math.min(value.length - 1, index));
48
+ tagButtonRefs.current[clamped]?.focus();
49
+ };
50
+
37
51
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
38
52
  if (e.key === "Enter" || e.key === delimiter) {
39
53
  e.preventDefault();
40
54
  addTag();
41
55
  } else if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
42
56
  removeTag(value.length - 1);
57
+ } else if (e.key === "ArrowLeft" && inputValue === "" && value.length > 0) {
58
+ // Boş input'ta sola ok → son etikete geç (hedefli silme için giriş noktası).
59
+ e.preventDefault();
60
+ focusTag(value.length - 1);
43
61
  }
44
62
  };
45
63
 
64
+ /**
65
+ * Etiketler arası klavye gezintisi. Önceden etiketlere yalnızca Tab ile
66
+ * ulaşılabiliyor ve hedefli silme yoktu — Backspace her zaman SON etiketi
67
+ * siliyordu, aradaki bir etiketi klavyeyle silmenin yolu yoktu.
68
+ */
69
+ const handleTagKeyDown =
70
+ (index: number) => (e: React.KeyboardEvent<HTMLButtonElement>) => {
71
+ switch (e.key) {
72
+ case "ArrowLeft":
73
+ e.preventDefault();
74
+ focusTag(index - 1);
75
+ break;
76
+ case "ArrowRight":
77
+ e.preventDefault();
78
+ if (index >= value.length - 1) innerInputRef.current?.focus();
79
+ else focusTag(index + 1);
80
+ break;
81
+ case "Home":
82
+ e.preventDefault();
83
+ focusTag(0);
84
+ break;
85
+ case "End":
86
+ e.preventDefault();
87
+ focusTag(value.length - 1);
88
+ break;
89
+ case "Delete":
90
+ case "Backspace": {
91
+ e.preventDefault();
92
+ const remaining = value.length - 1;
93
+ removeTag(index);
94
+ // Silinenin yerine geçen etikete odaklan; liste bittiyse input'a dön.
95
+ requestAnimationFrame(() => {
96
+ if (remaining > 0) focusTag(Math.min(index, remaining - 1));
97
+ else innerInputRef.current?.focus();
98
+ });
99
+ break;
100
+ }
101
+ default:
102
+ break;
103
+ }
104
+ };
105
+
46
106
  const addTag = () => {
47
107
  const tag = inputValue.trim();
48
-
108
+
49
109
  if (!tag) return;
50
-
110
+
51
111
  if (!allowDuplicates && value.includes(tag)) {
52
112
  setError("This tag already exists");
53
113
  setTimeout(() => setError(""), 2000);
54
114
  return;
55
115
  }
56
-
116
+
57
117
  if (maxTags && value.length >= maxTags) {
58
118
  setError(`Maximum ${maxTags} tags allowed`);
59
119
  setTimeout(() => setError(""), 2000);
60
120
  return;
61
121
  }
62
-
122
+
63
123
  onChange([...value, tag]);
64
124
  setInputValue("");
65
125
  setError("");
@@ -75,23 +135,28 @@ const TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(
75
135
  <div
76
136
  className={cn(
77
137
  "flex items-center min-h-[2.5rem] w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
78
- "dark:bg-zinc-950/50 dark:border-zinc-800",
138
+ // NOT: burada `dark:bg-zinc-950/50 dark:border-zinc-800` hardcode'u
139
+ // vardı ve aynı satırdaki `bg-background`/`border-input` token'larını
140
+ // karanlık modda eziyordu. Token'lar zaten dark-mode duyarlı.
79
141
  "focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
80
142
  disabled && "cursor-not-allowed opacity-50",
81
143
  className
82
144
  )}
83
145
  >
84
146
  {value.map((tag, index) => (
85
- <Badge
86
- key={index}
87
- variant={variant}
88
- className="gap-1 pr-1.5"
89
- >
147
+ <Badge key={index} variant={variant} className="gap-1 pr-1.5">
90
148
  <span className="text-xs">{tag}</span>
91
149
  {!disabled && (
92
150
  <button
151
+ ref={(el) => {
152
+ tagButtonRefs.current[index] = el;
153
+ }}
93
154
  type="button"
155
+ // Buton içeriği yalnızca X ikonu — erişilebilir ad olmadan
156
+ // ekran okuyucu hangi etiketin silineceğini söyleyemez.
157
+ aria-label={`Remove ${tag}`}
94
158
  onClick={() => removeTag(index)}
159
+ onKeyDown={handleTagKeyDown(index)}
95
160
  className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
96
161
  >
97
162
  <X className="h-3 w-3 text-muted-foreground hover:text-foreground transition-colors" />
@@ -100,7 +165,7 @@ const TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(
100
165
  </Badge>
101
166
  ))}
102
167
  <input
103
- ref={ref}
168
+ ref={innerInputRef}
104
169
  type="text"
105
170
  value={inputValue}
106
171
  onChange={(e) => setInputValue(e.target.value)}
@@ -108,9 +173,14 @@ const TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(
108
173
  onBlur={addTag}
109
174
  disabled={disabled}
110
175
  placeholder={value.length === 0 ? placeholder : ""}
176
+ // Etiket eklendikçe placeholder kalkıyor; erişilebilir ad olmadan
177
+ // input tamamen adsız kalıyordu. Tüketici kendi aria-label'ını
178
+ // verirse aşağıdaki {...props} yayılımı bunu ezer.
179
+ aria-label="Add tag"
180
+ aria-describedby={error ? errorId : undefined}
181
+ aria-invalid={error ? true : undefined}
111
182
  className={cn(
112
183
  "flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
113
- "dark:placeholder:text-zinc-500",
114
184
  "min-w-[120px]",
115
185
  disabled && "cursor-not-allowed"
116
186
  )}
@@ -118,7 +188,9 @@ const TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(
118
188
  />
119
189
  </div>
120
190
  {error && (
121
- <p className="mt-1 text-xs text-destructive">{error}</p>
191
+ <p id={errorId} role="status" className="mt-1 text-xs text-destructive">
192
+ {error}
193
+ </p>
122
194
  )}
123
195
  </div>
124
196
  );
@@ -127,4 +199,4 @@ const TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(
127
199
 
128
200
  TagsInput.displayName = "TagsInput";
129
201
 
130
- export { TagsInput };
202
+ export { TagsInput };
@@ -99,6 +99,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
99
99
  onChange,
100
100
  ...props
101
101
  }, ref) => {
102
+ // Mesaj id'leri `props.id` yoksa sabit string'e düşüyordu -> aynı sayfada
103
+ // iki id'siz Textarea'da yinelenen DOM id'si oluşuyordu.
104
+ const reactId = React.useId()
105
+ const fieldId = props.id || reactId
102
106
  const textareaRef = React.useRef<HTMLTextAreaElement>(null)
103
107
  const [internalValue, setInternalValue] = React.useState(value || defaultValue || "")
104
108
 
@@ -172,8 +176,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
172
176
  maxLength={maxLength}
173
177
  aria-invalid={!!error || undefined}
174
178
  aria-describedby={
175
- errorMessage ? `${props.id || ''}-error` :
176
- successMessage ? `${props.id || ''}-success` :
179
+ errorMessage ? `${fieldId}-error` :
180
+ successMessage ? `${fieldId}-success` :
177
181
  undefined
178
182
  }
179
183
  {...props}
@@ -197,8 +201,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
197
201
  messageClassName
198
202
  )}
199
203
  id={
200
- errorMessage ? `${props.id || ''}-error` :
201
- successMessage ? `${props.id || ''}-success` :
204
+ errorMessage ? `${fieldId}-error` :
205
+ successMessage ? `${fieldId}-success` :
202
206
  undefined
203
207
  }
204
208
  >