@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.
- package/dist/hooks/index.global.js +1 -6
- package/dist/index.d.mts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.global.js +52 -365
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +307 -288
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +271 -252
- package/dist/index.mjs.map +1 -1
- package/dist/lib/theme.global.js +1 -14
- package/dist/lib/theme.global.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ui/__tests__/carousel.test.tsx +37 -0
- package/src/components/ui/__tests__/quality-group-de.test.tsx +159 -0
- package/src/components/ui/carousel.tsx +24 -0
- package/src/components/ui/command.tsx +25 -0
- package/src/components/ui/scroll-area.tsx +23 -3
- package/src/components/ui/skeleton.tsx +16 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 }),
|