@moontra/moonui 6.8.0 → 6.9.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,159 @@
1
+ /**
2
+ * Grup D+E — #320 skeleton, #308 bundle (free tarafı).
3
+ *
4
+ * Her test, düzeltme geri alındığında kırmızıya dönecek biçimde yazıldı; canary ile
5
+ * doğrulandı (bkz. changelog).
6
+ */
7
+ import React from 'react'
8
+ import { render, screen } from '@testing-library/react'
9
+ import '@testing-library/jest-dom'
10
+
11
+ import { Skeleton, SkeletonText } from '../skeleton'
12
+ import { ScrollArea } from '../scroll-area'
13
+ import {
14
+ Command,
15
+ CommandInput,
16
+ CommandList,
17
+ CommandGroup,
18
+ CommandItem,
19
+ CommandSeparator,
20
+ } from '../command'
21
+ import {
22
+ Carousel,
23
+ CarouselContent,
24
+ CarouselItem,
25
+ } from '../carousel'
26
+
27
+ // cmdk seçili öğeyi görünür alana kaydırır; jsdom `scrollIntoView` implement etmez.
28
+ // Ortam eksiği — test edilen davranışla ilgisi yok.
29
+ beforeAll(() => {
30
+ if (!Element.prototype.scrollIntoView) {
31
+ Element.prototype.scrollIntoView = function scrollIntoView() {}
32
+ }
33
+ })
34
+
35
+ describe('#320 skeleton — a11y konvansiyonu', () => {
36
+ it('yer tutucu erişilebilirlik ağacından çıkarılır', () => {
37
+ const { container } = render(<Skeleton data-testid="sk" />)
38
+ expect(container.querySelector('[data-testid="sk"]')).toHaveAttribute('aria-hidden', 'true')
39
+ })
40
+
41
+ it('N skeleton, N canlı bölge ÜRETMEZ (role="status" seçilmemesinin gerekçesi)', () => {
42
+ const { container } = render(<SkeletonText lines={5} />)
43
+ // Tek yükleme durumu onlarca skeleton basar; her biri canlı bölge olsaydı ekran
44
+ // okuyucu aynı olayı N kez duyururdu. Duyurunun sahibi spinner'dır.
45
+ expect(container.querySelectorAll('[role="status"]')).toHaveLength(0)
46
+ expect(container.querySelectorAll('[aria-live]')).toHaveLength(0)
47
+ })
48
+
49
+ it('isLoaded && children dalı GERÇEK içerik → gizlenmez', () => {
50
+ render(
51
+ <Skeleton isLoaded data-testid="loaded">
52
+ <span>gerçek içerik</span>
53
+ </Skeleton>
54
+ )
55
+ const el = screen.getByTestId('loaded')
56
+ expect(el).not.toHaveAttribute('aria-hidden')
57
+ expect(screen.getByText('gerçek içerik')).toBeInTheDocument()
58
+ })
59
+ })
60
+
61
+ describe('#308-1 scroll-area — klavye erişimi', () => {
62
+ it('viewport varsayılan olarak odaklanabilir (WCAG 2.1.1)', () => {
63
+ const { container } = render(
64
+ <ScrollArea className="h-20">
65
+ <p>odaklanabilir eleman içermeyen içerik</p>
66
+ </ScrollArea>
67
+ )
68
+ const viewport = container.querySelector('[data-radix-scroll-area-viewport]')
69
+ expect(viewport).not.toBeNull()
70
+ expect(viewport).toHaveAttribute('tabindex', '0')
71
+ })
72
+
73
+ it('focusable={false} ile sekme durağı kaldırılabilir', () => {
74
+ const { container } = render(
75
+ <ScrollArea focusable={false} className="h-20">
76
+ <p>içerik</p>
77
+ </ScrollArea>
78
+ )
79
+ expect(container.querySelector('[data-radix-scroll-area-viewport]'))
80
+ .not.toHaveAttribute('tabindex')
81
+ })
82
+ })
83
+
84
+ describe('#308-2 carousel — slayt duyurusu', () => {
85
+ it('aria-live durum bölgesi var ve atomik', () => {
86
+ const { container } = render(
87
+ <Carousel>
88
+ <CarouselContent>
89
+ <CarouselItem>1</CarouselItem>
90
+ <CarouselItem>2</CarouselItem>
91
+ </CarouselContent>
92
+ </Carousel>
93
+ )
94
+ const live = container.querySelector('[aria-live="polite"]')
95
+ expect(live).not.toBeNull()
96
+ expect(live).toHaveAttribute('aria-atomic', 'true')
97
+ expect(live).toHaveClass('sr-only')
98
+ })
99
+
100
+ /**
101
+ * Duyuru METNİNİN biçimi (`"Slide 2 of 5"`) burada DOĞRULANMIYOR — kasıtlı.
102
+ * jsdom'da her eleman sıfır boyutlu olduğu için embla'nın `scrollSnapList()` sonucu
103
+ * güvenilmez: tek item'lı carousel'de 1 snap, üç item'lıda 0 snap raporlandı. Bu sayıya
104
+ * dayanan bir assert, ürünü değil jsdom'un düzen eksikliğini sabitlerdi.
105
+ * Biçim ve slayt değişiminde güncellenme GERÇEK TARAYICIDA doğrulandı (bkz. changelog).
106
+ */
107
+ it('slayt sayısı BİLİNMİYORKEN boş duyuru basmaz', () => {
108
+ // Embla init olmadan (`total === 0`) metin boş kalmalı — aksi hâlde ekran okuyucuya
109
+ // "Slide 0 of 0" gider. Slayt İÇERMEYEN carousel bu durumu doğrudan üretir.
110
+ const { container } = render(
111
+ <Carousel>
112
+ <CarouselContent />
113
+ </Carousel>
114
+ )
115
+ expect(container.querySelector('[aria-live="polite"]')!.textContent).toBe('')
116
+ })
117
+ })
118
+
119
+ describe('#308-5/6 command', () => {
120
+ it('separator erişilebilirlik ağacından çıkarılır (listbox çocuk kuralı)', () => {
121
+ const { container } = render(
122
+ <Command>
123
+ <CommandInput placeholder="ara" />
124
+ <CommandList>
125
+ <CommandGroup heading="A">
126
+ <CommandItem value="bir">Bir</CommandItem>
127
+ </CommandGroup>
128
+ <CommandSeparator data-testid="sep" alwaysRender />
129
+ <CommandGroup heading="B">
130
+ <CommandItem value="iki">İki</CommandItem>
131
+ </CommandGroup>
132
+ </CommandList>
133
+ </Command>
134
+ )
135
+
136
+ const sep = container.querySelector('[data-testid="sep"]')
137
+ expect(sep).not.toBeNull()
138
+ // cmdk `role="separator"` basar ve prop'lardan SONRA yazdığı için ezilemez;
139
+ // `aria-hidden` ile ağaçtan çıkarılır → listbox'ın sahiplendiği çocuk olmaktan çıkar.
140
+ expect(sep).toHaveAttribute('aria-hidden', 'true')
141
+ })
142
+
143
+ it('listbox\'ın gizlenmemiş çocuğu separator DEĞİL', () => {
144
+ const { container } = render(
145
+ <Command>
146
+ <CommandList>
147
+ <CommandGroup heading="A">
148
+ <CommandItem value="bir">Bir</CommandItem>
149
+ </CommandGroup>
150
+ <CommandSeparator alwaysRender />
151
+ </CommandList>
152
+ </Command>
153
+ )
154
+ const listbox = container.querySelector('[role="listbox"]')!
155
+ const gorunurCocuklar = Array.from(listbox.querySelectorAll('[role="separator"]'))
156
+ .filter((el) => el.getAttribute('aria-hidden') !== 'true')
157
+ expect(gorunurCocuklar).toHaveLength(0)
158
+ })
159
+ })
@@ -89,12 +89,25 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
89
89
  );
90
90
  const [canScrollPrev, setCanScrollPrev] = React.useState(false);
91
91
  const [canScrollNext, setCanScrollNext] = React.useState(false);
92
+ /**
93
+ * #308-2: `aria-roledescription="carousel"/"slide"` doğru set edilmişti ama slayt
94
+ * DEĞİŞİMİNİ duyuran hiçbir şey yoktu — klavye/buton ile ilerleyen ekran-okuyucu
95
+ * kullanıcısı hangi slaytta olduğunu öğrenemiyordu. Embla'nın zaten dinlenen "select"
96
+ * olayına bağlı bir `aria-live="polite"` durum bölgesi eklendi.
97
+ */
98
+ const [slideState, setSlideState] = React.useState<{ current: number; total: number }>(
99
+ { current: 0, total: 0 }
100
+ );
92
101
 
93
102
  // Embla "select" olayında ileri/geri butonlarının durumunu güncelle
94
103
  const onSelect = React.useCallback((emblaApi: CarouselApi) => {
95
104
  if (!emblaApi) return;
96
105
  setCanScrollPrev(emblaApi.canScrollPrev());
97
106
  setCanScrollNext(emblaApi.canScrollNext());
107
+ setSlideState({
108
+ current: emblaApi.selectedScrollSnap() + 1,
109
+ total: emblaApi.scrollSnapList().length,
110
+ });
98
111
  }, []);
99
112
 
100
113
  const scrollPrev = React.useCallback(() => {
@@ -164,6 +177,17 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
164
177
  {...props}
165
178
  >
166
179
  {children}
180
+ {/*
181
+ #308-2: Görsel olarak gizli durum bölgesi. `aria-live="polite"` sayesinde slayt
182
+ değişimi (buton, klavye ya da sürükleme fark etmeksizin) duyurulur; `aria-atomic`
183
+ metnin tamamının okunmasını sağlar. Slayt sayısı henüz bilinmiyorken (ilk render,
184
+ embla init olmadan) hiçbir şey basılmaz — boş duyuru üretmemek için.
185
+ */}
186
+ <div aria-live="polite" aria-atomic="true" className="sr-only">
187
+ {slideState.total > 0
188
+ ? `Slide ${slideState.current} of ${slideState.total}`
189
+ : ""}
190
+ </div>
167
191
  </div>
168
192
  </CarouselContext.Provider>
169
193
  );
@@ -167,6 +167,18 @@ const CommandSeparator = React.forwardRef<
167
167
  >(({ className, ...props }, ref) => (
168
168
  <CommandPrimitive.Separator
169
169
  ref={ref}
170
+ /**
171
+ * #308-6: cmdk, Separator'a `role="separator"` basıyor ve bu eleman
172
+ * `CommandList`'in (`role="listbox"`) çocuğu oluyor → listbox'ın izinli çocuğu
173
+ * yalnız `option`/`group` olduğu için axe `aria-required-children` (critical) veriyordu.
174
+ *
175
+ * `role` EZİLEMİYOR: cmdk 1.1.1 `{...props, "cmdk-separator":"", role:"separator"}`
176
+ * sırasıyla yazıyor, yani kendi rolü prop'lardan SONRA geliyor (kaynak doğrulandı).
177
+ * `aria-hidden` ise prop yayılımının içinde olduğu için geçiyor — ve ayraç zaten
178
+ * tümüyle dekoratif olduğundan erişilebilirlik ağacından çıkarılması DOĞRU davranış.
179
+ * Böylece listbox'ın sahiplendiği çocuklar arasında görünmez.
180
+ */
181
+ aria-hidden="true"
170
182
  className={cn("moonui-theme", "-mx-1 h-px bg-border", className)}
171
183
  {...props}
172
184
  />
@@ -219,4 +231,17 @@ export {
219
231
  CommandItem,
220
232
  CommandShortcut,
221
233
  CommandSeparator,
234
+ }
235
+
236
+ // #308-5: Sekiz Props interface'inin HİÇBİRİ export edilmiyordu → tüketici sarmalayıcı
237
+ // yazarken tipi yeniden türetmek zorundaydı.
238
+ export type {
239
+ CommandProps,
240
+ CommandDialogProps,
241
+ CommandInputProps,
242
+ CommandListProps,
243
+ CommandEmptyProps,
244
+ CommandGroupProps,
245
+ CommandSeparatorProps,
246
+ CommandItemProps,
222
247
  }
@@ -5,16 +5,36 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5
5
 
6
6
  import { cn } from "../../lib/utils"
7
7
 
8
+ export interface ScrollAreaProps
9
+ extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
10
+ /**
11
+ * #308-1: Kaydırma alanının klavyeyle odaklanabilir olup olmayacağı. Varsayılan `true`
12
+ * — odaklanabilir içeriği olmayan bir kaydırma alanı aksi hâlde klavyeyle erişilemez
13
+ * (WCAG 2.1.1). Fazladan sekme durağı istenmeyen durumlarda `false`.
14
+ */
15
+ focusable?: boolean
16
+ }
17
+
8
18
  const ScrollArea = React.forwardRef<
9
19
  React.ElementRef<typeof ScrollAreaPrimitive.Root>,
10
- React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
11
- >(({ className, children, ...props }, ref) => (
20
+ ScrollAreaProps
21
+ >(({ className, children, focusable = true, ...props }, ref) => (
12
22
  <ScrollAreaPrimitive.Root
13
23
  ref={ref}
14
24
  className={cn("relative overflow-hidden", className)}
15
25
  {...props}
16
26
  >
17
- <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
27
+ {/*
28
+ #308-1: Viewport'a `tabIndex` VERİLMİYORDU ve bu davranış Radix'ten kalıtsal DEĞİL
29
+ (`@radix-ui/react-scroll-area` kaynağında tabIndex geçmiyor). Sonuç: içinde
30
+ odaklanabilir eleman bulunmayan bir ScrollArea klavyeyle HİÇ kaydırılamıyordu
31
+ (WCAG 2.1.1). Kaydırılabilir bölgenin odaklanabilir olması kanonik çözümdür;
32
+ ek sekme durağı istemeyen tüketici `focusable={false}` diyebilir.
33
+ */}
34
+ <ScrollAreaPrimitive.Viewport
35
+ tabIndex={focusable ? 0 : undefined}
36
+ className="h-full w-full rounded-[inherit]"
37
+ >
18
38
  {children}
19
39
  </ScrollAreaPrimitive.Viewport>
20
40
  <ScrollBar />
@@ -144,10 +144,25 @@ export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
144
144
  }
145
145
 
146
146
  const SkeletonElement = useMotion ? motion.div : "div";
147
-
147
+
148
148
  return (
149
149
  <SkeletonElement
150
150
  ref={ref}
151
+ /**
152
+ * #320: Skeleton hiçbir a11y işareti taşımıyordu — ne `aria-hidden`, ne `role`,
153
+ * ne `aria-busy`. Radix kullanmadığı için kalıtsal semantik de yok.
154
+ *
155
+ * Konvansiyon olarak `aria-hidden` seçildi (`role="status"` DEĞİL): tek bir yükleme
156
+ * durumu tipik olarak ONLARCA skeleton render eder (`SkeletonText` tek başına N satır
157
+ * basar) — her biri canlı bölge olsaydı ekran okuyucu aynı olayı N kez duyururdu.
158
+ * "Yükleniyor" duyurusunun sahibi `spinner.tsx:79-105` (`role="status"` +
159
+ * `aria-live="polite"` + `sr-only` etiket); skeleton onun görsel yer tutucusudur.
160
+ * Tüketici kapsayıcıya `aria-busy` koyarak durumu bildirir.
161
+ *
162
+ * Yalnız YER TUTUCU dalında: `isLoaded && children` dalı gerçek içerik döndürür ve
163
+ * yukarıda kasıtlı olarak işaretlenmemiştir.
164
+ */
165
+ aria-hidden="true"
151
166
  className={cn(
152
167
  "moonui-theme",
153
168
  skeletonVariants({ variant, size, shape, animation }),