@moontra/moonui 3.3.0 → 3.4.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.
@@ -490,4 +490,100 @@ describe('Slider Component', () => {
490
490
  expect(thumbs).toHaveLength(1)
491
491
  })
492
492
  })
493
+
494
+ // Regresyon koruması: thumb'lar `role="slider"` + `tabIndex=0` taşıyıp
495
+ // odaklanabiliyordu ama hiçbir klavye handler'ı yoktu — ARIA sözleşmesi
496
+ // slider vaat ediyor, bileşen yalnızca fareyle çalışıyordu (WCAG 2.1.1).
497
+ describe('Klavye ile değer değiştirme', () => {
498
+ it('ArrowRight ve ArrowUp değeri step kadar artırır', () => {
499
+ const onValueChange = jest.fn()
500
+ render(
501
+ <Slider defaultValue={[50]} min={0} max={100} step={1} onValueChange={onValueChange} />
502
+ )
503
+ const thumb = screen.getByRole('slider')
504
+
505
+ fireEvent.keyDown(thumb, { key: 'ArrowRight' })
506
+ expect(onValueChange).toHaveBeenLastCalledWith([51])
507
+
508
+ fireEvent.keyDown(thumb, { key: 'ArrowUp' })
509
+ expect(onValueChange).toHaveBeenLastCalledWith([52])
510
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '52')
511
+ })
512
+
513
+ it('ArrowLeft ve ArrowDown değeri step kadar azaltır', () => {
514
+ const onValueChange = jest.fn()
515
+ render(
516
+ <Slider defaultValue={[50]} min={0} max={100} step={5} onValueChange={onValueChange} />
517
+ )
518
+ const thumb = screen.getByRole('slider')
519
+
520
+ fireEvent.keyDown(thumb, { key: 'ArrowLeft' })
521
+ expect(onValueChange).toHaveBeenLastCalledWith([45])
522
+
523
+ fireEvent.keyDown(thumb, { key: 'ArrowDown' })
524
+ expect(onValueChange).toHaveBeenLastCalledWith([40])
525
+ })
526
+
527
+ it('Home ve End uç değerlere taşır', () => {
528
+ const onValueChange = jest.fn()
529
+ render(
530
+ <Slider defaultValue={[50]} min={10} max={90} step={1} onValueChange={onValueChange} />
531
+ )
532
+ const thumb = screen.getByRole('slider')
533
+
534
+ fireEvent.keyDown(thumb, { key: 'Home' })
535
+ expect(onValueChange).toHaveBeenLastCalledWith([10])
536
+
537
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'End' })
538
+ expect(onValueChange).toHaveBeenLastCalledWith([90])
539
+ })
540
+
541
+ it('PageUp ve PageDown 10x step uygular', () => {
542
+ const onValueChange = jest.fn()
543
+ render(
544
+ <Slider defaultValue={[50]} min={0} max={100} step={2} onValueChange={onValueChange} />
545
+ )
546
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'PageUp' })
547
+ expect(onValueChange).toHaveBeenLastCalledWith([70])
548
+ })
549
+
550
+ it('min/max sınırlarını aşmaz', () => {
551
+ const onValueChange = jest.fn()
552
+ render(
553
+ <Slider defaultValue={[100]} min={0} max={100} step={1} onValueChange={onValueChange} />
554
+ )
555
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' })
556
+ expect(onValueChange).toHaveBeenLastCalledWith([100])
557
+ })
558
+
559
+ it('disabled iken hiçbir tuş değeri değiştirmez', () => {
560
+ const onValueChange = jest.fn()
561
+ render(
562
+ <Slider defaultValue={[50]} min={0} max={100} disabled onValueChange={onValueChange} />
563
+ )
564
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' })
565
+ expect(onValueChange).not.toHaveBeenCalled()
566
+ })
567
+
568
+ it('çoklu thumb komşusunun ötesine geçmez', () => {
569
+ const onValueChange = jest.fn()
570
+ render(
571
+ <Slider defaultValue={[40, 41]} min={0} max={100} step={1} onValueChange={onValueChange} />
572
+ )
573
+
574
+ // Soldaki thumb sağdakine (41) kadar çıkabilir, ötesine geçemez.
575
+ fireEvent.keyDown(screen.getAllByRole('slider')[0], { key: 'ArrowRight' })
576
+ expect(onValueChange).toHaveBeenLastCalledWith([41, 41])
577
+
578
+ fireEvent.keyDown(screen.getAllByRole('slider')[0], { key: 'ArrowRight' })
579
+ expect(onValueChange).toHaveBeenLastCalledWith([41, 41])
580
+ })
581
+
582
+ it('eşlenmemiş tuşlar değeri değiştirmez', () => {
583
+ const onValueChange = jest.fn()
584
+ render(<Slider defaultValue={[50]} onValueChange={onValueChange} />)
585
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'a' })
586
+ expect(onValueChange).not.toHaveBeenCalled()
587
+ })
588
+ })
493
589
  })
@@ -290,4 +290,34 @@ describe('ToggleGroup Components', () => {
290
290
  expect(first).toHaveFocus()
291
291
  })
292
292
  })
293
+
294
+ describe('Orientation', () => {
295
+ it('varsayılan/yatay yerleşimde taban sınıflar korunur', () => {
296
+ render(
297
+ <ToggleGroup type="single" data-testid="group">
298
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
299
+ </ToggleGroup>
300
+ )
301
+
302
+ const group = screen.getByTestId('group')
303
+ expect(group).toHaveClass('inline-flex', 'items-center', 'justify-center')
304
+ })
305
+
306
+ it('orientation="vertical" dikey yerleşim sınıflarını devreye alır', () => {
307
+ render(
308
+ <ToggleGroup type="single" orientation="vertical" data-testid="group">
309
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
310
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
311
+ </ToggleGroup>
312
+ )
313
+
314
+ const group = screen.getByTestId('group')
315
+ // Radix roving-focus köke data-orientation yazar; dikey sınıflar buna bağlıdır.
316
+ // Eskiden kök className sabitti: `orientation` tip yüzeyinde vardı ama
317
+ // yerleşime hiç yansımıyordu (kardeş button-group bunu doğru yapıyor).
318
+ expect(group).toHaveAttribute('data-orientation', 'vertical')
319
+ expect(group.className).toContain('data-[orientation=vertical]:flex-col')
320
+ expect(group.className).toContain('data-[orientation=vertical]:items-stretch')
321
+ })
322
+ })
293
323
  })
@@ -15,14 +15,14 @@ import { cn } from "../../lib/utils";
15
15
  */
16
16
 
17
17
  const checkboxVariants = cva(
18
- "peer shrink-0 border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-primary-foreground",
18
+ "peer shrink-0 border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-primary-foreground data-[state=indeterminate]:text-primary-foreground",
19
19
  {
20
20
  variants: {
21
21
  variant: {
22
- default: "border-border bg-background data-[state=checked]:bg-primary data-[state=checked]:border-primary",
23
- outline: "border-border bg-transparent data-[state=checked]:bg-primary data-[state=checked]:border-primary",
24
- muted: "border-border bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary",
25
- ghost: "border-transparent bg-transparent hover:bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary",
22
+ default: "border-border bg-background data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
23
+ outline: "border-border bg-transparent data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
24
+ muted: "border-border bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
25
+ ghost: "border-transparent bg-transparent hover:bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
26
26
  },
27
27
  size: {
28
28
  sm: "h-3.5 w-3.5",
@@ -80,16 +80,18 @@ const Checkbox = React.forwardRef<
80
80
  checked,
81
81
  ...props
82
82
  }, ref) => {
83
- // Indeterminate state yönetimi
84
- const [isIndeterminate, setIsIndeterminate] = React.useState(indeterminate);
85
-
86
- React.useEffect(() => {
87
- setIsIndeterminate(indeterminate);
88
- }, [indeterminate]);
83
+ // Radix'in `CheckedState` tipi: boolean | "indeterminate".
84
+ // "indeterminate" iletildiğinde Radix kökte `data-state="indeterminate"` +
85
+ // `aria-checked="mixed"` üretir ve Indicator'ı render eder — Minus ikonu ancak
86
+ // bu durumda görünür olur.
87
+ //
88
+ // Eskiden burada `false` iletiliyordu: kök hep `data-state="unchecked"` kalıyor,
89
+ // Radix Indicator unchecked durumda render edilmediği için Minus hiç görünmüyor
90
+ // ve ekran okuyucu "kısmen seçili" durumunu duyuramıyordu.
91
+ //
92
+ // `checked` undefined ise olduğu gibi geçirilir — uncontrolled kullanım korunur.
93
+ const effectiveChecked = indeterminate ? "indeterminate" : checked;
89
94
 
90
- // Checked state override, indeterminate olduğunda
91
- const effectiveChecked = isIndeterminate ? false : checked;
92
-
93
95
  return (
94
96
  <CheckboxPrimitive.Root
95
97
  ref={ref}
@@ -103,7 +105,7 @@ const Checkbox = React.forwardRef<
103
105
  animation === "bounce" && "data-[state=checked]:animate-bounce"
104
106
  )}
105
107
  >
106
- {isIndeterminate ? (
108
+ {indeterminate ? (
107
109
  <Minus className="h-[65%] w-[65%]" />
108
110
  ) : icon ? (
109
111
  icon
@@ -117,7 +119,7 @@ const Checkbox = React.forwardRef<
117
119
  Checkbox.displayName = CheckboxPrimitive.Root.displayName;
118
120
 
119
121
  // CheckboxGroup bileşeni
120
- interface CheckboxGroupProps extends React.HTMLAttributes<HTMLDivElement> {
122
+ export interface CheckboxGroupProps extends React.HTMLAttributes<HTMLDivElement> {
121
123
  /**
122
124
  * Grup içi yerleşim - dikey veya yatay
123
125
  */
@@ -154,7 +156,7 @@ const CheckboxGroup = React.forwardRef<HTMLDivElement, CheckboxGroupProps>(
154
156
  CheckboxGroup.displayName = "CheckboxGroup";
155
157
 
156
158
  // CheckboxLabel bileşeni
157
- interface CheckboxLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
159
+ export interface CheckboxLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
158
160
  /**
159
161
  * Checkbox bileşeni için HTML id
160
162
  */
@@ -195,7 +197,7 @@ const CheckboxLabel = React.forwardRef<HTMLLabelElement, CheckboxLabelProps>(
195
197
  CheckboxLabel.displayName = "CheckboxLabel";
196
198
 
197
199
  // Checkbox ve Label içeren bileşen
198
- interface CheckboxWithLabelProps extends CheckboxProps {
200
+ export interface CheckboxWithLabelProps extends CheckboxProps {
199
201
  /**
200
202
  * Label içeriği
201
203
  */
@@ -528,8 +528,14 @@ export function ColorPicker({
528
528
  {presets.map((presetColor) => (
529
529
  <button
530
530
  key={presetColor}
531
+ type="button"
532
+ // Swatch'ın tek içeriği arka plan rengi — erişilebilir ad
533
+ // olmadan ekran okuyucu yalnızca "button" diye okur.
534
+ aria-label={`Select color ${presetColor}`}
535
+ aria-pressed={color === presetColor}
531
536
  className={cn(
532
537
  "h-7 w-7 rounded border-2 transition-all hover:scale-110",
538
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
533
539
  color === presetColor
534
540
  ? "border-primary"
535
541
  : "border-transparent"
@@ -578,8 +584,14 @@ export function SimpleColorPicker({
578
584
  {colors.map((color) => (
579
585
  <button
580
586
  key={color}
587
+ type="button"
588
+ // Swatch'ın tek içeriği arka plan rengi — erişilebilir ad olmadan
589
+ // ekran okuyucu yalnızca "button" diye okur.
590
+ aria-label={`Select color ${color}`}
591
+ aria-pressed={value === color}
581
592
  className={cn(
582
593
  "rounded-full border-2 transition-all hover:scale-110",
594
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
583
595
  sizeClasses[size],
584
596
  value === color ? "border-primary ring-2 ring-primary/20" : "border-transparent"
585
597
  )}
@@ -125,6 +125,9 @@ export {
125
125
 
126
126
  export type {
127
127
  CheckboxProps as MoonUICheckboxProps,
128
+ CheckboxGroupProps as MoonUICheckboxGroupProps,
129
+ CheckboxLabelProps as MoonUICheckboxLabelProps,
130
+ CheckboxWithLabelProps as MoonUICheckboxWithLabelProps,
128
131
  } from "./checkbox";
129
132
 
130
133
  // Collapsible
@@ -302,6 +305,9 @@ export {
302
305
 
303
306
  export type {
304
307
  RadioGroupProps as MoonUIRadioGroupProps,
308
+ RadioGroupItemProps as MoonUIRadioGroupItemProps,
309
+ RadioLabelProps as MoonUIRadioLabelProps,
310
+ RadioItemWithLabelProps as MoonUIRadioItemWithLabelProps,
305
311
  } from "./radio-group";
306
312
 
307
313
  // RichTextEditor
@@ -356,6 +362,11 @@ export type {
356
362
  // Slider
357
363
  export { Slider as MoonUISlider } from "./slider";
358
364
 
365
+ export type {
366
+ SliderProps as MoonUISliderProps,
367
+ SliderBaseProps as MoonUISliderBaseProps,
368
+ } from "./slider";
369
+
359
370
  // SwipeableCard
360
371
  export { SwipeableCard as MoonUISwipeableCard } from "./swipeable-card";
361
372
 
@@ -36,11 +36,20 @@ const radioGroupItemVariants = cva(
36
36
  }
37
37
  );
38
38
 
39
- export interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
39
+ // NOT: `React.HTMLAttributes` kendi `defaultValue`'sunu
40
+ // (`string | number | readonly string[]`) taşır; radio grubu için tek bir `string`
41
+ // anlamlı olduğundan Omit ile daraltılır.
42
+ export interface RadioGroupProps
43
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue"> {
40
44
  /**
41
- * Radio group value
45
+ * Radio group value (controlled)
42
46
  */
43
47
  value?: string;
48
+ /**
49
+ * Uncontrolled kullanımda başlangıç değeri. `value` verilmediğinde grup
50
+ * seçimi kendi iç state'inde tutar.
51
+ */
52
+ defaultValue?: string;
44
53
  /**
45
54
  * Function to call when radio group value changes
46
55
  */
@@ -63,9 +72,34 @@ const RadioGroupContext = React.createContext<{
63
72
  }>({});
64
73
 
65
74
  const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
66
- ({ className, value, onValueChange, disabled, name, ...props }, ref) => {
75
+ ({ className, value, defaultValue, onValueChange, disabled, name, ...props }, ref) => {
76
+ // Controlled/uncontrolled ikili mod: `value` verilmişse tüketici yönetir,
77
+ // verilmemişse grup seçimi kendi iç state'inde tutar.
78
+ const [internalValue, setInternalValue] = React.useState<string | undefined>(
79
+ defaultValue
80
+ );
81
+ const isControlled = value !== undefined;
82
+ const currentValue = isControlled ? value : internalValue;
83
+
84
+ const handleValueChange = React.useCallback(
85
+ (next: string) => {
86
+ if (!isControlled) {
87
+ setInternalValue(next);
88
+ }
89
+ onValueChange?.(next);
90
+ },
91
+ [isControlled, onValueChange]
92
+ );
93
+
67
94
  return (
68
- <RadioGroupContext.Provider value={{ value, onValueChange, disabled, name }}>
95
+ <RadioGroupContext.Provider
96
+ value={{
97
+ value: currentValue,
98
+ onValueChange: handleValueChange,
99
+ disabled,
100
+ name,
101
+ }}
102
+ >
69
103
  <div
70
104
  ref={ref}
71
105
  role="radiogroup"
@@ -131,7 +165,7 @@ const RadioGroupItem = React.forwardRef<
131
165
  disabled={disabled || radioGroup.disabled}
132
166
  name={radioGroup.name}
133
167
  onChange={handleChange}
134
- className="sr-only"
168
+ className="peer sr-only"
135
169
  {...props}
136
170
  />
137
171
  <label
@@ -139,7 +173,12 @@ const RadioGroupItem = React.forwardRef<
139
173
  className={cn(
140
174
  radioGroupItemVariants({ variant, size }),
141
175
  "rounded-full",
142
- "focus-visible:ring-primary/50",
176
+ // Gerçekte odaklanan eleman görsel olarak gizli `sr-only` input'tur.
177
+ // Görünür label onun KARDEŞİ olduğu için label'da `:focus-visible` hiç
178
+ // tetiklenmiyor, dolayısıyla klavye kullanıcısı hiçbir focus izi
179
+ // görmüyordu. Tailwind `peer` deseniyle (input `peer` sınıfını taşır ve
180
+ // DOM'da label'dan önce gelir) focus izi görünür elemana taşınır.
181
+ "peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-primary/50",
143
182
  "relative inline-flex shrink-0 cursor-pointer items-center justify-center overflow-hidden",
144
183
  disabled && "cursor-not-allowed opacity-50",
145
184
  className
@@ -158,7 +197,7 @@ const RadioGroupItem = React.forwardRef<
158
197
  RadioGroupItem.displayName = "RadioGroupItem";
159
198
 
160
199
  // Radio Label Component
161
- interface RadioLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
200
+ export interface RadioLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
162
201
  /**
163
202
  * HTML id for radio button
164
203
  */
@@ -194,7 +233,7 @@ const RadioLabel = React.forwardRef<HTMLLabelElement, RadioLabelProps>(
194
233
  RadioLabel.displayName = "RadioLabel";
195
234
 
196
235
  // Radio Item and Label combination
197
- interface RadioItemWithLabelProps extends RadioGroupItemProps {
236
+ export interface RadioItemWithLabelProps extends RadioGroupItemProps {
198
237
  /**
199
238
  * Label content
200
239
  */
@@ -97,7 +97,7 @@ const sliderThumbVariants = cva(
97
97
  )
98
98
 
99
99
  // Custom type definition for component properties
100
- type SliderBaseProps = {
100
+ export type SliderBaseProps = {
101
101
  /**
102
102
  * Track variant
103
103
  */
@@ -161,7 +161,7 @@ type SliderBaseProps = {
161
161
  }
162
162
 
163
163
  // Merge HTML properties without defaultValue conflicts
164
- type SliderProps = SliderBaseProps & Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue'>
164
+ export type SliderProps = SliderBaseProps & Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue'>
165
165
 
166
166
  const Slider = React.forwardRef<
167
167
  HTMLDivElement,
@@ -280,6 +280,59 @@ const Slider = React.forwardRef<
280
280
  document.addEventListener('mouseup', handleMouseUp);
281
281
  };
282
282
 
283
+ // Klavyeyle değer değiştirme (WCAG 2.1.1 Keyboard).
284
+ //
285
+ // Thumb'lar `role="slider"` + `aria-valuenow` + `tabIndex=0` taşıyıp
286
+ // odaklanabiliyordu, ancak hiçbir klavye handler'ı yoktu: bileşen ARIA
287
+ // sözleşmesinde slider olduğunu duyuruyor ama yalnızca fareyle çalışıyordu.
288
+ // Tuş eşlemesi Radix Slider davranışını izler.
289
+ const handleThumbKeyDown = (index: number) => (event: React.KeyboardEvent) => {
290
+ if (disabled) return;
291
+
292
+ const largeStep = step * 10;
293
+ const current = sliderValue[index];
294
+ let next: number;
295
+
296
+ switch (event.key) {
297
+ case "ArrowRight":
298
+ case "ArrowUp":
299
+ next = current + step;
300
+ break;
301
+ case "ArrowLeft":
302
+ case "ArrowDown":
303
+ next = current - step;
304
+ break;
305
+ case "PageUp":
306
+ next = current + largeStep;
307
+ break;
308
+ case "PageDown":
309
+ next = current - largeStep;
310
+ break;
311
+ case "Home":
312
+ next = min;
313
+ break;
314
+ case "End":
315
+ next = max;
316
+ break;
317
+ default:
318
+ return;
319
+ }
320
+
321
+ // Yalnızca yukarıdaki tuşlarda varsayılan davranışı engelle (sayfa kaydırma).
322
+ event.preventDefault();
323
+
324
+ // Çoklu thumb'da komşu değerleri aşma; tek thumb'da min/max sınırları geçerli.
325
+ const lowerBound = index > 0 ? sliderValue[index - 1] : min;
326
+ const upperBound =
327
+ index < sliderValue.length - 1 ? sliderValue[index + 1] : max;
328
+ const bounded = Math.max(lowerBound, Math.min(upperBound, next));
329
+
330
+ const newValues = [...sliderValue];
331
+ newValues[index] = bounded;
332
+ // handleValueChange, step hassasiyetine göre yuvarlamayı zaten uyguluyor.
333
+ handleValueChange(newValues);
334
+ };
335
+
283
336
  return (
284
337
  <div className="w-full" ref={ref} {...props}>
285
338
  {showValueLabel && (
@@ -332,6 +385,7 @@ const Slider = React.forwardRef<
332
385
  cursor: disabled ? 'not-allowed' : 'pointer',
333
386
  }}
334
387
  onMouseDown={handleThumbMouseDown(i)}
388
+ onKeyDown={handleThumbKeyDown(i)}
335
389
  aria-label={`Thumb ${i + 1}`}
336
390
  tabIndex={disabled ? -1 : 0}
337
391
  role="slider"
@@ -48,6 +48,10 @@ const ToggleGroup = React.forwardRef<
48
48
  ref={ref}
49
49
  className={cn(
50
50
  "moonui-theme inline-flex items-center justify-center gap-1",
51
+ // Radix roving-focus kök elemana `data-orientation` yazar; dikey yerleşim
52
+ // buna bağlanır. Yatay/tanımsız durumda taban sınıflar geçerli kalır —
53
+ // yani mevcut render değişmez. Kardeş `button-group` ile desen paritesi.
54
+ "data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[orientation=vertical]:justify-start",
51
55
  className
52
56
  )}
53
57
  {...props}