@moontra/moonui 4.0.0 → 5.0.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.
@@ -17,11 +17,15 @@ const aspectRatioVariants = cva(
17
17
  "relative overflow-hidden",
18
18
  {
19
19
  variants: {
20
+ // `variant` yalnız yüzey/kenarlık eksenini yönetir. Önceden her varyant
21
+ // ayrıca `rounded-md` taşıyordu ve `radius` ekseniyle çakışıyordu
22
+ // (aynı anda iki `rounded-*` sınıfı üretiliyordu). Köşe yuvarlaması artık
23
+ // TEK kaynaktan: `radius`.
20
24
  variant: {
21
- default: "rounded-md bg-muted/10",
25
+ default: "bg-muted/10",
22
26
  ghost: "bg-transparent",
23
- outline: "rounded-md border border-border",
24
- card: "rounded-md bg-card shadow-sm",
27
+ outline: "border border-border",
28
+ card: "bg-card shadow-sm",
25
29
  },
26
30
  radius: {
27
31
  none: "rounded-none",
@@ -33,6 +37,8 @@ const aspectRatioVariants = cva(
33
37
  },
34
38
  defaultVariants: {
35
39
  variant: "default",
40
+ // Önceki davranış korunur: her varyant `rounded-md` taşıyordu.
41
+ radius: "md",
36
42
  },
37
43
  }
38
44
  )
@@ -51,16 +51,16 @@ const badgeVariants = cva(
51
51
  "dark:bg-error/90 dark:text-white dark:shadow-inner dark:shadow-error/10",
52
52
  ],
53
53
  success: [
54
- "border-transparent bg-success text-white",
54
+ "border-transparent bg-success text-success-foreground",
55
55
  "hover:bg-success/90 dark:hover:bg-success/80",
56
56
  "focus-visible:ring-success/30 dark:focus-visible:ring-success/25",
57
- "dark:bg-success/90 dark:text-white dark:shadow-inner dark:shadow-success/10",
57
+ "dark:bg-success/90 dark:text-success-foreground dark:shadow-inner dark:shadow-success/10",
58
58
  ],
59
59
  warning: [
60
- "border-transparent bg-warning text-white",
60
+ "border-transparent bg-warning text-warning-foreground",
61
61
  "hover:bg-warning/90 dark:hover:bg-warning/80",
62
62
  "focus-visible:ring-warning/30 dark:focus-visible:ring-warning/25",
63
- "dark:bg-warning/90 dark:text-gray-900 dark:shadow-inner dark:shadow-warning/10",
63
+ "dark:bg-warning/90 dark:text-warning-foreground dark:shadow-inner dark:shadow-warning/10",
64
64
  ],
65
65
  ghost: [
66
66
  "border border-gray-200 bg-gray-50/50 text-gray-700",
@@ -205,6 +205,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
205
205
  className={cn("moonui-theme", buttonVariants({ variant, size, rounded, fullWidth, className }))}
206
206
  ref={ref}
207
207
  disabled={disabled || loading}
208
+ // Görsel spinner + data-loading vardı ama yardımcı teknolojiye meşgul
209
+ // durumu hiç duyurulmuyordu.
210
+ aria-busy={loading || undefined}
208
211
  data-loading={loading ? "" : undefined}
209
212
  {...props}
210
213
  >
@@ -121,7 +121,12 @@ export interface DatePickerProps
121
121
  calendarProps?: React.ComponentProps<typeof Calendar>;
122
122
  }
123
123
 
124
- export function DatePicker({
124
+ // NOT (issue #264): dört seçici de düz fonksiyondu, ref geçme yolu yoktu.
125
+ // Her biri KENDİ gerçek kök DOM elemanına bağlanır — DOM yapısı değişmez.
126
+ // DatePicker/DateTimePicker kök bir <div> render eder; DateRangePicker ve
127
+ // MonthPicker'ın kökü Radix `Popover`'dır ve kendisi DOM üretmez, bu yüzden
128
+ // onlarda ref tetikleyici butona gider.
129
+ export const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(function DatePicker({
125
130
  className,
126
131
  variant,
127
132
  size,
@@ -143,7 +148,7 @@ export function DatePicker({
143
148
  calendarProps,
144
149
  disabled,
145
150
  ...props
146
- }: DatePickerProps) {
151
+ }: DatePickerProps, ref: React.ForwardedRef<HTMLDivElement>) {
147
152
  const [open, setOpen] = React.useState(false);
148
153
  const [internalDate, setInternalDate] = React.useState<Date | undefined>(value);
149
154
 
@@ -163,7 +168,11 @@ export function DatePicker({
163
168
  }
164
169
  };
165
170
 
166
- const handleClear = (e: React.MouseEvent) => {
171
+ // Hem fare hem klavye (Enter/Space) yolundan çağrılıyor; yalnızca
172
+ // `stopPropagation` kullanıldığı için ortak taban tipi yeterli.
173
+ // (Önceden `React.MouseEvent` bekliyordu ve klavye yolunda `as any` ile
174
+ // zorlanıyordu.)
175
+ const handleClear = (e: React.SyntheticEvent) => {
167
176
  e.stopPropagation();
168
177
  handleSelect(undefined);
169
178
  };
@@ -188,7 +197,7 @@ export function DatePicker({
188
197
  const iconElement = icon || <CalendarIcon className="h-4 w-4" />;
189
198
 
190
199
  return (
191
- <div className="moonui-theme">
200
+ <div ref={ref} className="moonui-theme">
192
201
  <Popover open={open} onOpenChange={setOpen}>
193
202
  <PopoverTrigger asChild>
194
203
  <Button
@@ -231,7 +240,7 @@ export function DatePicker({
231
240
  onKeyDown={(e) => {
232
241
  if (e.key === 'Enter' || e.key === ' ') {
233
242
  e.preventDefault();
234
- handleClear(e as any);
243
+ handleClear(e);
235
244
  }
236
245
  }}
237
246
  >
@@ -255,7 +264,7 @@ export function DatePicker({
255
264
  </Button>
256
265
  </PopoverTrigger>
257
266
  <PopoverContent
258
- className="w-[360px] p-0 shadow-2xl rounded-2xl border border-gray-200 dark:border-gray-700 bg-background/95 backdrop-blur-sm !z-[9999]"
267
+ className="w-[360px] p-0 shadow-2xl rounded-2xl border border-border bg-background/95 backdrop-blur-sm !z-[9999]"
259
268
  align="start"
260
269
  sideOffset={12}
261
270
  >
@@ -289,7 +298,8 @@ export function DatePicker({
289
298
  </Popover>
290
299
  </div>
291
300
  );
292
- }
301
+ });
302
+ DatePicker.displayName = "DatePicker";
293
303
 
294
304
  /**
295
305
  * DateRangePicker Component
@@ -313,7 +323,7 @@ export interface DateRangePickerProps
313
323
  separator?: string;
314
324
  }
315
325
 
316
- export function DateRangePicker({
326
+ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePickerProps>(function DateRangePicker({
317
327
  className,
318
328
  value,
319
329
  onChange,
@@ -321,7 +331,7 @@ export function DateRangePicker({
321
331
  formatString = "LLL dd, y",
322
332
  separator = " - ",
323
333
  ...props
324
- }: DateRangePickerProps) {
334
+ }: DateRangePickerProps, ref: React.ForwardedRef<HTMLButtonElement>) {
325
335
  const [open, setOpen] = React.useState(false);
326
336
  const [internalRange, setInternalRange] = React.useState(value);
327
337
 
@@ -352,6 +362,7 @@ export function DateRangePicker({
352
362
  <Popover open={open} onOpenChange={setOpen}>
353
363
  <PopoverTrigger asChild>
354
364
  <Button
365
+ ref={ref}
355
366
  variant={props.variant === "ghost" ? "ghost" : "outline"}
356
367
  className={cn(
357
368
  datePickerVariants({
@@ -370,7 +381,7 @@ export function DateRangePicker({
370
381
  </span>
371
382
  </Button>
372
383
  </PopoverTrigger>
373
- <PopoverContent className="w-auto p-0 shadow-2xl rounded-2xl border border-gray-200 dark:border-gray-700 bg-background/95 backdrop-blur-sm !z-[9999]" align="start" sideOffset={12}>
384
+ <PopoverContent className="w-auto p-0 shadow-2xl rounded-2xl border border-border bg-background/95 backdrop-blur-sm !z-[9999]" align="start" sideOffset={12}>
374
385
  <Calendar
375
386
  mode="range"
376
387
  defaultMonth={internalRange?.from}
@@ -383,7 +394,8 @@ export function DateRangePicker({
383
394
  </PopoverContent>
384
395
  </Popover>
385
396
  );
386
- }
397
+ });
398
+ DateRangePicker.displayName = "DateRangePicker";
387
399
 
388
400
  /**
389
401
  * DateTimePicker Component
@@ -406,7 +418,7 @@ export interface DateTimePickerProps extends DatePickerProps {
406
418
  timeInterval?: number;
407
419
  }
408
420
 
409
- export function DateTimePicker({
421
+ export const DateTimePicker = React.forwardRef<HTMLDivElement, DateTimePickerProps>(function DateTimePicker({
410
422
  value,
411
423
  onChange,
412
424
  formatString = "PPP p",
@@ -414,7 +426,7 @@ export function DateTimePicker({
414
426
  timeFormat = "24",
415
427
  timeInterval = 15,
416
428
  ...props
417
- }: DateTimePickerProps) {
429
+ }: DateTimePickerProps, ref: React.ForwardedRef<HTMLDivElement>) {
418
430
  const [date, setDate] = React.useState<Date | undefined>(value);
419
431
  const [time, setTime] = React.useState<string>(
420
432
  value ? format(value, timeFormat === "24" ? "HH:mm" : "hh:mm a") : "00:00"
@@ -469,7 +481,7 @@ export function DateTimePicker({
469
481
  }, [timeFormat, timeInterval]);
470
482
 
471
483
  return (
472
- <div className="flex flex-col gap-2">
484
+ <div ref={ref} className="flex flex-col gap-2">
473
485
  <DatePicker
474
486
  {...props}
475
487
  value={date}
@@ -500,7 +512,8 @@ export function DateTimePicker({
500
512
  )}
501
513
  </div>
502
514
  );
503
- }
515
+ });
516
+ DateTimePicker.displayName = "DateTimePicker";
504
517
 
505
518
  /**
506
519
  * MonthPicker Component
@@ -515,13 +528,13 @@ export interface MonthPickerProps extends Omit<DatePickerProps, "formatString">
515
528
  formatString?: string;
516
529
  }
517
530
 
518
- export function MonthPicker({
531
+ export const MonthPicker = React.forwardRef<HTMLButtonElement, MonthPickerProps>(function MonthPicker({
519
532
  value,
520
533
  onChange,
521
534
  placeholder = "Pick a month",
522
535
  formatString = "MMMM yyyy",
523
536
  ...props
524
- }: MonthPickerProps) {
537
+ }: MonthPickerProps, ref: React.ForwardedRef<HTMLButtonElement>) {
525
538
  const [open, setOpen] = React.useState(false);
526
539
  const [viewDate, setViewDate] = React.useState(value || new Date());
527
540
 
@@ -550,6 +563,7 @@ export function MonthPicker({
550
563
  <Popover open={open} onOpenChange={setOpen}>
551
564
  <PopoverTrigger asChild>
552
565
  <Button
566
+ ref={ref}
553
567
  variant={props.variant === "default" ? "outline" : (props.variant || "outline")}
554
568
  className={cn(
555
569
  datePickerVariants({
@@ -566,12 +580,15 @@ export function MonthPicker({
566
580
  {displayValue || placeholder}
567
581
  </Button>
568
582
  </PopoverTrigger>
569
- <PopoverContent className="w-64 p-0 shadow-2xl border border-gray-200 dark:border-gray-700 bg-background !z-[9999]" align="start">
583
+ <PopoverContent className="w-64 p-0 shadow-2xl border border-border bg-background !z-[9999]" align="start">
570
584
  <div className="p-3">
571
585
  <div className="flex items-center justify-between mb-3">
586
+ {/* İkon-only butonlar: erişilebilir ad olmadan ekran okuyucu
587
+ yalnızca "button" der, hangi yöne gidileceği anlaşılmaz. */}
572
588
  <Button
573
589
  variant="outline"
574
590
  size="icon"
591
+ aria-label={`Previous year, ${viewDate.getFullYear() - 1}`}
575
592
  onClick={() => handleYearChange(-1)}
576
593
  >
577
594
  <ChevronLeft className="h-4 w-4" />
@@ -582,6 +599,7 @@ export function MonthPicker({
582
599
  <Button
583
600
  variant="outline"
584
601
  size="icon"
602
+ aria-label={`Next year, ${viewDate.getFullYear() + 1}`}
585
603
  onClick={() => handleYearChange(1)}
586
604
  >
587
605
  <ChevronRight className="h-4 w-4" />
@@ -604,7 +622,8 @@ export function MonthPicker({
604
622
  </PopoverContent>
605
623
  </Popover>
606
624
  );
607
- }
625
+ });
626
+ MonthPicker.displayName = "MonthPicker";
608
627
 
609
628
  // Re-export utilities
610
629
  export { format } from "date-fns";
@@ -325,6 +325,10 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
325
325
  multiple={multiple}
326
326
  disabled={disabled}
327
327
  onChange={handleFileSelect}
328
+ // Input görsel olarak gizli (opacity-0) ve ilişkili bir <label>'ı
329
+ // yok — erişilebilir ad olmadan ekran okuyucu bunu adsız bir dosya
330
+ // alanı olarak duyuruyordu.
331
+ aria-label={multiple ? "Choose files to upload" : "Choose a file to upload"}
328
332
  className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
329
333
  />
330
334
 
@@ -257,6 +257,10 @@ export type {
257
257
  // Label
258
258
  export { Label as MoonUILabel } from "./label";
259
259
 
260
+ export type {
261
+ LabelProps as MoonUILabelProps,
262
+ } from "./label";
263
+
260
264
  // LockedComponent
261
265
  export {
262
266
  LockedComponent as MoonUILockedComponent,
@@ -352,6 +356,10 @@ export { Separator as MoonUISeparator } from "./separator";
352
356
  // SimpleEditor
353
357
  export { SimpleEditor as MoonUISimpleEditor } from "./simple-editor";
354
358
 
359
+ export type {
360
+ SimpleEditorProps as MoonUISimpleEditorProps,
361
+ } from "./simple-editor";
362
+
355
363
  // Skeleton
356
364
  export {
357
365
  Skeleton as MoonUISkeleton,
@@ -406,6 +414,14 @@ export {
406
414
  // TagsInput
407
415
  export { TagsInput as MoonUITagsInput } from "./tags-input";
408
416
 
417
+ export type {
418
+ TagsInputProps as MoonUITagsInputProps,
419
+ } from "./tags-input";
420
+
421
+ export type {
422
+ ToggleProps as MoonUIToggleProps,
423
+ } from "./toggle";
424
+
409
425
  // Textarea
410
426
  export { Textarea as MoonUITextarea } from "./textarea";
411
427
 
@@ -32,6 +32,11 @@ const InputOTP = React.forwardRef<
32
32
  >(({ className, containerClassName, ...props }, ref) => (
33
33
  <OTPInput
34
34
  ref={ref}
35
+ // input-otp gerçek girişi görsel olarak gizli bir <input> ile yapar ve o
36
+ // input'un ilişkili bir <label>'ı yok — erişilebilir ad olmadan ekran
37
+ // okuyucu adsız bir metin alanı duyuruyordu. Tüketici kendi aria-label'ını
38
+ // verirse aşağıdaki {...props} yayılımı bunu ezer.
39
+ aria-label="One-time password"
35
40
  containerClassName={cn(
36
41
  "moonui-theme",
37
42
  "flex items-center gap-2 has-[:disabled]:opacity-50",
@@ -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}
@@ -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",
@@ -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
  >