@moontra/moonui 6.24.0 → 6.26.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 (56) hide show
  1. package/dist/cdn/moonui.css +2 -2
  2. package/dist/cdn/moonui.esm.js +12 -12
  3. package/dist/cdn/moonui.global.js +12 -12
  4. package/dist/index.d.mts +24 -6
  5. package/dist/index.d.ts +24 -6
  6. package/dist/index.js +436 -93
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +437 -95
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/issue-544-kontrast.test.tsx +153 -145
  12. package/src/__tests__/issue-645-overlay-tab-focus.test.tsx +262 -0
  13. package/src/__tests__/issue-646-dark-scope-override.test.tsx +297 -0
  14. package/src/__tests__/issue-661-a11y.test.tsx +113 -0
  15. package/src/__tests__/issue-661-token-kontrast.test.tsx +82 -0
  16. package/src/components/ui/__tests__/accent-foreground-contrast.test.ts +197 -0
  17. package/src/components/ui/__tests__/accordion.test.tsx +6 -4
  18. package/src/components/ui/__tests__/alert.test.tsx +4 -1
  19. package/src/components/ui/__tests__/button.test.tsx +6 -1
  20. package/src/components/ui/__tests__/dark-token-contrast.test.ts +18 -18
  21. package/src/components/ui/__tests__/heading-level-policy.test.tsx +4 -4
  22. package/src/components/ui/__tests__/issue-643-card-token-parite.test.tsx +113 -0
  23. package/src/components/ui/__tests__/issue-649-button-aschild.test.tsx +199 -0
  24. package/src/components/ui/__tests__/issue-664-separator-dekoratif-rol.test.tsx +41 -0
  25. package/src/components/ui/__tests__/preset-color-shadowing.test.ts +10 -10
  26. package/src/components/ui/__tests__/primary-subtle-token.test.ts +7 -7
  27. package/src/components/ui/__tests__/progress-secondary-contrast.test.ts +38 -38
  28. package/src/components/ui/__tests__/progress-warning-error-contrast.test.ts +64 -64
  29. package/src/components/ui/__tests__/quality-group-de.test.tsx +2 -2
  30. package/src/components/ui/__tests__/responsive-color-invariance.test.ts +20 -20
  31. package/src/components/ui/__tests__/select.test.tsx +6 -1
  32. package/src/components/ui/__tests__/semantic-token-usage.test.ts +23 -23
  33. package/src/components/ui/__tests__/size-scale-convention.test.ts +10 -10
  34. package/src/components/ui/__tests__/slider-accent-contrast.test.ts +142 -136
  35. package/src/components/ui/__tests__/slider-secondary-contrast.test.ts +60 -60
  36. package/src/components/ui/__tests__/slider-warning-error-contrast.test.ts +100 -100
  37. package/src/components/ui/__tests__/switch-variant-contrast.test.tsx +103 -103
  38. package/src/components/ui/__tests__/tooltip.test.tsx +122 -0
  39. package/src/components/ui/accordion.tsx +16 -3
  40. package/src/components/ui/alert.tsx +1 -1
  41. package/src/components/ui/badge.tsx +12 -1
  42. package/src/components/ui/button.tsx +95 -24
  43. package/src/components/ui/card.tsx +59 -5
  44. package/src/components/ui/dropdown-menu.tsx +99 -5
  45. package/src/components/ui/file-upload.tsx +4 -0
  46. package/src/components/ui/index.ts +4 -1
  47. package/src/components/ui/popover-pro.tsx +41 -0
  48. package/src/components/ui/popover.tsx +162 -7
  49. package/src/components/ui/rating.tsx +24 -2
  50. package/src/components/ui/select.tsx +4 -4
  51. package/src/components/ui/simple-editor.tsx +3 -18
  52. package/src/components/ui/tabs.tsx +16 -1
  53. package/src/components/ui/textarea.tsx +6 -1
  54. package/src/components/ui/toast.tsx +17 -2
  55. package/src/components/ui/tooltip.tsx +154 -2
  56. package/src/styles/tokens.css +34 -2
@@ -0,0 +1,262 @@
1
+ /**
2
+ * issue #645 — 3 overlay açıkken Tab ne odak tuzağı kuruyor ne çıkış sağlıyor.
3
+ *
4
+ * KÖK NEDEN (ikisi de Radix'ten devralınmıştı, sarmalayıcı kusuru değildi):
5
+ * - `@radix-ui/react-menu` → `MenuContentImpl`: `if (event.key === "Tab")
6
+ * event.preventDefault();` — Tab yutuluyor ama karşılığında hiçbir şey
7
+ * yapılmıyor (menü kapanmıyor, odak ilerlemiyor).
8
+ * - `@radix-ui/react-popover` → `PopoverContentImpl`: içerik her zaman
9
+ * `FocusScope`'a `loop: true` ile sarılıyor; `FocusScope` `!loop && !trapped`
10
+ * kontrolü yüzünden non-modal popover'da da Tab'ı yutuyor/döngüye sokuyor.
11
+ *
12
+ * SÖZLEŞME:
13
+ * - dropdown-menu = WAI-ARIA "menu button": Tab menüyü KAPATIR, odak tetikleyiciye döner.
14
+ * - popover = non-modal disclosure: Tab kenarda overlay'den ÇIKAR (ve popover kapanır).
15
+ * - `modal` popover'da tuzak KORUNUR.
16
+ * - Escape davranışı DEĞİŞMEZ.
17
+ */
18
+ import * as React from "react"
19
+ import { render, screen, waitFor } from "@testing-library/react"
20
+ import userEvent from "@testing-library/user-event"
21
+
22
+ import {
23
+ DropdownMenu,
24
+ DropdownMenuContent,
25
+ DropdownMenuItem,
26
+ DropdownMenuLabel,
27
+ DropdownMenuTrigger,
28
+ } from "../components/ui/dropdown-menu"
29
+ import {
30
+ Popover,
31
+ PopoverContent,
32
+ PopoverTrigger,
33
+ } from "../components/ui/popover"
34
+
35
+ function MenuFixture() {
36
+ return (
37
+ <div>
38
+ <button type="button">before</button>
39
+ <DropdownMenu>
40
+ <DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
41
+ <DropdownMenuContent>
42
+ <DropdownMenuLabel>My Account</DropdownMenuLabel>
43
+ <DropdownMenuItem>Profile</DropdownMenuItem>
44
+ <DropdownMenuItem>Billing</DropdownMenuItem>
45
+ </DropdownMenuContent>
46
+ </DropdownMenu>
47
+ <button type="button">after</button>
48
+ </div>
49
+ )
50
+ }
51
+
52
+ function PopoverFixture({
53
+ modal = false,
54
+ withFields = false,
55
+ }: {
56
+ modal?: boolean
57
+ withFields?: boolean
58
+ }) {
59
+ return (
60
+ <div>
61
+ <button type="button">before</button>
62
+ <Popover modal={modal}>
63
+ <PopoverTrigger>Open popover</PopoverTrigger>
64
+ <PopoverContent>
65
+ <h4>Popover</h4>
66
+ {withFields ? (
67
+ <>
68
+ <button type="button">inner-first</button>
69
+ <button type="button">inner-last</button>
70
+ </>
71
+ ) : (
72
+ <p>A popover with no focusable content.</p>
73
+ )}
74
+ </PopoverContent>
75
+ </Popover>
76
+ <button type="button">after</button>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ describe("issue #645 — dropdown-menu (WAI-ARIA menü sözleşmesi)", () => {
82
+ it("Tab menüyü kapatır ve odağı tetikleyiciye döndürür", async () => {
83
+ const user = userEvent.setup()
84
+ render(<MenuFixture />)
85
+
86
+ const trigger = screen.getByText("Open Menu")
87
+ await user.click(trigger)
88
+ expect(await screen.findByRole("menu")).toBeInTheDocument()
89
+
90
+ await user.tab()
91
+
92
+ // KUSURUN KİLİDİ: düzeltme geri alınırsa menü açık kalır ve bu bekleme kırmızıya döner.
93
+ await waitFor(() => {
94
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument()
95
+ })
96
+ await waitFor(() => {
97
+ expect(document.activeElement).toBe(trigger)
98
+ })
99
+ })
100
+
101
+ it("Shift+Tab de aynı sözleşmeyi uygular", async () => {
102
+ const user = userEvent.setup()
103
+ render(<MenuFixture />)
104
+
105
+ await user.click(screen.getByText("Open Menu"))
106
+ expect(await screen.findByRole("menu")).toBeInTheDocument()
107
+
108
+ await user.tab({ shift: true })
109
+
110
+ await waitFor(() => {
111
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument()
112
+ })
113
+ })
114
+
115
+ it("Escape davranışı korunur (kapanır + odak tetikleyiciye döner)", async () => {
116
+ const user = userEvent.setup()
117
+ render(<MenuFixture />)
118
+
119
+ const trigger = screen.getByText("Open Menu")
120
+ await user.click(trigger)
121
+ expect(await screen.findByRole("menu")).toBeInTheDocument()
122
+
123
+ await user.keyboard("{Escape}")
124
+
125
+ await waitFor(() => {
126
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument()
127
+ })
128
+ await waitFor(() => {
129
+ expect(document.activeElement).toBe(trigger)
130
+ })
131
+ })
132
+
133
+ it("kontrollü kullanım (`open`/`onOpenChange`) bozulmaz", async () => {
134
+ const user = userEvent.setup()
135
+ const onOpenChange = jest.fn()
136
+
137
+ function Controlled() {
138
+ const [open, setOpen] = React.useState(false)
139
+ return (
140
+ <DropdownMenu
141
+ open={open}
142
+ onOpenChange={(next) => {
143
+ onOpenChange(next)
144
+ setOpen(next)
145
+ }}
146
+ >
147
+ <DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
148
+ <DropdownMenuContent>
149
+ <DropdownMenuItem>Profile</DropdownMenuItem>
150
+ </DropdownMenuContent>
151
+ </DropdownMenu>
152
+ )
153
+ }
154
+
155
+ render(<Controlled />)
156
+ await user.click(screen.getByText("Open Menu"))
157
+ expect(await screen.findByRole("menu")).toBeInTheDocument()
158
+ expect(onOpenChange).toHaveBeenCalledWith(true)
159
+
160
+ await user.tab()
161
+ await waitFor(() => {
162
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument()
163
+ })
164
+ expect(onOpenChange).toHaveBeenCalledWith(false)
165
+ })
166
+ })
167
+
168
+ describe("issue #645 — popover (non-modal disclosure sözleşmesi)", () => {
169
+ it("odaklanabilir içerik YOKKEN Tab konteynerde donmaz, dışarı çıkar", async () => {
170
+ const user = userEvent.setup()
171
+ render(<PopoverFixture />)
172
+
173
+ await user.click(screen.getByText("Open popover"))
174
+ const content = await screen.findByRole("dialog")
175
+ await waitFor(() => expect(document.activeElement).toBe(content))
176
+
177
+ await user.tab()
178
+
179
+ // KUSURUN KİLİDİ: düzeltme geri alınırsa odak `content`te kalır.
180
+ expect(document.activeElement).not.toBe(content)
181
+ expect(document.activeElement).toBe(screen.getByText("after"))
182
+ })
183
+
184
+ it("odaklanabilir içerik VARKEN son elemandan sonra dışarı çıkar", async () => {
185
+ const user = userEvent.setup()
186
+ render(<PopoverFixture withFields />)
187
+
188
+ await user.click(screen.getByText("Open popover"))
189
+ await screen.findByRole("dialog")
190
+ // Radix açılışta içerikteki ilk odaklanabilir elemana odaklanır.
191
+ await waitFor(() =>
192
+ expect(document.activeElement).toBe(screen.getByText("inner-first"))
193
+ )
194
+
195
+ const innerFirst = screen.getByText("inner-first")
196
+ await user.tab()
197
+ expect(document.activeElement).toBe(screen.getByText("inner-last"))
198
+
199
+ // Radix `loop: true` burada başa döndürüyordu; artık dışarı çıkmalı.
200
+ await user.tab()
201
+ expect(document.activeElement).not.toBe(innerFirst)
202
+ expect(document.activeElement).toBe(screen.getByText("after"))
203
+ // Non-modal disclosure: odak dışarı çıkınca popover kapanır.
204
+ await waitFor(() => {
205
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
206
+ })
207
+ })
208
+
209
+ it("Shift+Tab ilk elemandan tetikleyiciye döner", async () => {
210
+ const user = userEvent.setup()
211
+ render(<PopoverFixture withFields />)
212
+
213
+ const trigger = screen.getByText("Open popover")
214
+ await user.click(trigger)
215
+ await screen.findByRole("dialog")
216
+ await waitFor(() =>
217
+ expect(document.activeElement).toBe(screen.getByText("inner-first"))
218
+ )
219
+
220
+ // Radix `loop: true` burada SON elemana döndürüyordu; artık tetikleyiciye dönmeli.
221
+ await user.tab({ shift: true })
222
+ expect(document.activeElement).not.toBe(screen.getByText("inner-last"))
223
+ expect(document.activeElement).toBe(trigger)
224
+ })
225
+
226
+ it("`modal` popover WAI-ARIA tuzağını KORUR (çıkış yapmaz)", async () => {
227
+ const user = userEvent.setup()
228
+ render(<PopoverFixture modal withFields />)
229
+
230
+ await user.click(screen.getByText("Open popover"))
231
+ const content = await screen.findByRole("dialog")
232
+ await waitFor(() =>
233
+ expect(document.activeElement).toBe(screen.getByText("inner-first"))
234
+ )
235
+
236
+ await user.tab()
237
+ await user.tab()
238
+ await user.tab()
239
+
240
+ // Modal'da odak asla `after` düğmesine sızmamalı.
241
+ expect(document.activeElement).not.toBe(screen.getByText("after"))
242
+ expect(content.contains(document.activeElement)).toBe(true)
243
+ })
244
+
245
+ it("Escape davranışı korunur (kapanır + odak tetikleyiciye döner)", async () => {
246
+ const user = userEvent.setup()
247
+ render(<PopoverFixture />)
248
+
249
+ const trigger = screen.getByText("Open popover")
250
+ await user.click(trigger)
251
+ expect(await screen.findByRole("dialog")).toBeInTheDocument()
252
+
253
+ await user.keyboard("{Escape}")
254
+
255
+ await waitFor(() => {
256
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
257
+ })
258
+ await waitFor(() => {
259
+ expect(document.activeElement).toBe(trigger)
260
+ })
261
+ })
262
+ })
@@ -0,0 +1,297 @@
1
+ /**
2
+ * #646 — cva'daki `dark:`-scope'lu arka plan hem component'in kendi metnini
3
+ * hem tüketicinin `className` override'ını eziyordu.
4
+ *
5
+ * ══ MEKANİZMA ══════════════════════════════════════════════════════════════════
6
+ * `tailwind-merge` yalnız AYNI değiştirici (modifier) kümesindeki sınıfları
7
+ * çakıştırır. Component `dark:bg-X`, tüketici öneksiz `bg-Y` yazdığında ikisi
8
+ * FARKLI grupta sayılır → ikisi de hayatta kalır → koyu temada `.dark .dark\:bg-X`
9
+ * daha yüksek özgüllükle KAZANIR. Tüketici zemini seçtiğini sanır, metnini ona
10
+ * göre ayarlar, koyu temada zemin bambaşkadır.
11
+ *
12
+ * Kusur, token'ın kendisi DOĞRUYKEN üstüne İKİNCİ bir katman eklenmesinden doğuyor:
13
+ * `--primary` koyu temada BİLİNÇLİ near-white (tokens.css:500, #368 — DEĞİŞMEZ) ve
14
+ * `bg-card` zaten kart zeminini taşıyor.
15
+ *
16
+ * ══ GERÇEK TARAYICI ÖLÇÜMLERİ (Chromium, localhost:3000, dark) ═════════════════
17
+ * gradient-flow `<Card className="bg-white/90">` → kart rgb(17,24,39)'da kalıyor
18
+ * `text-gray-900` çocukları TAM 1.00:1 (4 düğüm) → 14.46 ✓
19
+ * `text-gray-700` 1.72 ✓→ 8.40 · `text-gray-600` 2.35 → 6.16
20
+ * tabs pills aktif sekme beyaz metin near-white zeminde 1.28:1 → 15.91 ✓
21
+ * badge primary varyantsız `<Badge className="bg-*-500/20 text-*-300">`
22
+ * 1.10–1.48:1 (magnetic-button + swipe-actions) → tüketici kazanıyor
23
+ *
24
+ * ══ ⚠️ ÖLÇÜMÜN ENGELLEDİĞİ REGRESYON (#545 "ihlal takası" dersi) ═══════════════
25
+ * Tabs `pills`te `dark:data-[state=active]:bg-primary/90` KALDIRILMADI. Kaldırmak
26
+ * aynı varyanttaki `dark:bg-gray-800` (inaktif zemin) ile eşit özgüllüğe (0,2,0)
27
+ * düşürüyor; kaynak sırası gereği inaktif zemin kazanıyor ve aktif pill koyu temada
28
+ * near-black metinle gray-800 üstünde 1.41:1 veriyordu — bir ihlali kapatıp
29
+ * yenisini açmak. Orada yalnız `dark:…text-white` kaldırıldı.
30
+ */
31
+ import * as React from 'react'
32
+ import { render } from '@testing-library/react'
33
+ import '@testing-library/jest-dom'
34
+ import * as fs from 'fs'
35
+ import * as path from 'path'
36
+ import { Badge, badgeVariants } from '../components/ui/badge'
37
+ import { Card, cardVariants } from '../components/ui/card'
38
+ import { tabsTriggerVariants } from '../components/ui/tabs'
39
+ import { cn } from '../lib/utils'
40
+
41
+ const PKG = path.resolve(__dirname, '..')
42
+ const tokens = fs.readFileSync(path.join(PKG, 'styles/tokens.css'), 'utf8')
43
+ const cardSrc = fs.readFileSync(path.join(PKG, 'components/ui/card.tsx'), 'utf8')
44
+ const badgeSrc = fs.readFileSync(path.join(PKG, 'components/ui/badge.tsx'), 'utf8')
45
+ const tabsSrc = fs.readFileSync(path.join(PKG, 'components/ui/tabs.tsx'), 'utf8')
46
+
47
+ type RGB = [number, number, number]
48
+ type Blok = 'light' | 'dark'
49
+
50
+ // ─── Renk aritmetiği (issue-544-kontrast.test.tsx ile birebir aynı yöntem) ───────
51
+ function tokenOku(name: string, blok: Blok): string | null {
52
+ const source =
53
+ blok === 'dark' ? tokens.slice(tokens.indexOf('.dark')) : tokens.slice(0, tokens.indexOf('.dark'))
54
+ return source.match(new RegExp(`--${name}:\\s*([^;]+);`))?.[1].trim() ?? null
55
+ }
56
+ function hslToRgb(h: number, s: number, l: number): RGB {
57
+ h /= 360
58
+ s /= 100
59
+ l /= 100
60
+ const f = (n: number) => {
61
+ const k = (n + h * 12) % 12
62
+ const a = s * Math.min(l, 1 - l)
63
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
64
+ }
65
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
66
+ }
67
+ const semanticRgb = (name: string, blok: Blok): RGB => {
68
+ const v = tokenOku(name, blok) ?? tokenOku(name, 'light')!
69
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
70
+ return hslToRgb(h, s, l)
71
+ }
72
+ function relativeLuminance([r, g, b]: RGB): number {
73
+ const c = (v: number) => {
74
+ const x = v / 255
75
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
76
+ }
77
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
78
+ }
79
+ function kontrast(a: RGB, b: RGB): number {
80
+ const la = relativeLuminance(a)
81
+ const lb = relativeLuminance(b)
82
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
83
+ return (hi + 0.05) / (lo + 0.05)
84
+ }
85
+ /** Yarı saydam rengi opak zemine bindir — `bg-primary/90` gibi sınıflar için ŞART */
86
+ const komposit = (ust: RGB, alt: RGB, alfa: number): RGB =>
87
+ ust.map((c, i) => Math.round(c * alfa + alt[i] * (1 - alfa))) as RGB
88
+
89
+ const WHITE: RGB = [255, 255, 255]
90
+ /** Docs kabuğunda ölçülen GERÇEK koyu kart (#284/#368/#448/#454/#544 konvansiyonu) */
91
+ const CARD_ACTUAL_DARK: RGB = [17, 24, 39]
92
+ const AA_TEXT = 4.5
93
+
94
+ /** Bir sınıf dizesini TOKEN'lara böl — `hover:bg-primary/90` gibi komşulara
95
+ * substring eşleşmesiyle takılmamak için (substring assert'i sessizce yanıltır). */
96
+ const parcala = (s: string): string[] => s.split(/\s+/).filter(Boolean)
97
+
98
+ /**
99
+ * Kaynak çapaları için YORUMLARI SÖK.
100
+ *
101
+ * ⚠️ Bu bu testin kendi yazımında ısırdı: düzeltmeyi ANLATAN yorum satırları
102
+ * kaldırılan sınıfın adını (`dark:bg-primary/90`) literal olarak içeriyor, bu
103
+ * yüzden ham kaynak üzerinde `not.toContain` yapmak testi yanlış sebeple kırmızı
104
+ * gösteriyordu. Çapa KODA bakmalı, kodun anlatısına değil.
105
+ */
106
+ const yorumsuz = (src: string): string =>
107
+ src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
108
+ /**
109
+ * DURAĞAN (resting) durumdaki `dark:bg-*` sınıfları.
110
+ *
111
+ * ⚠️ `dark:hover:bg-*` / `dark:data-[state=…]:bg-*` BİLEREK KAPSAM DIŞI: bu issue
112
+ * durağan durumu ölçüyor (axe/QC de öyle) ve durum-kapılı dallarda `dark:` özgüllüğü
113
+ * bazen ZORUNLU (bkz. tabs `pills` özgüllük kilidi testi).
114
+ */
115
+ const darkArkaPlanlar = (s: string): string[] =>
116
+ parcala(s).filter((c) => /^dark:bg-/.test(c))
117
+
118
+ // ════════════════════════════════════════════════════════════════════════════════
119
+ describe('#646 — hesaplayıcı çapaları (sessiz-yeşil koruması)', () => {
120
+ it('ÇAPA: siyah↔beyaz tam 21.00', () => {
121
+ expect(kontrast([0, 0, 0], WHITE)).toBeCloseTo(21, 2)
122
+ })
123
+
124
+ it('ÇAPA: hesaplayıcı, GERÇEK TARAYICI ölçümünü birebir üretiyor', () => {
125
+ // Chromium'da `bg-primary` koyu temada rgb(248,250,252),
126
+ // `text-primary-foreground` rgb(2,2,5) olarak ölçüldü.
127
+ expect(semanticRgb('primary', 'dark')).toEqual([248, 250, 252])
128
+ expect(semanticRgb('primary-foreground', 'dark')).toEqual([2, 2, 5])
129
+ // `bg-primary/90` koyu kart üstünde rgb(225,227,231) ölçüldü.
130
+ expect(komposit(semanticRgb('primary', 'dark'), CARD_ACTUAL_DARK, 0.9)).toEqual([225, 227, 231])
131
+ })
132
+
133
+ it('NEGATİF KONTROL: eşik mantığı ayrım yapıyor — ESKİ ihlal GERÇEKTEN ihlaldi', () => {
134
+ // Kaldırılan `dark:…text-white` + `dark:bg-primary/90` çifti: 1.28:1 (issue gövdesi)
135
+ const eskiZemin = komposit(semanticRgb('primary', 'dark'), CARD_ACTUAL_DARK, 0.9)
136
+ expect(kontrast(eskiZemin, WHITE)).toBeCloseTo(1.28, 2)
137
+ expect(kontrast(eskiZemin, WHITE)).toBeLessThan(AA_TEXT)
138
+ })
139
+ })
140
+
141
+ // ════════════════════════════════════════════════════════════════════════════════
142
+ describe('#646 — KAPSAM KİLİDİ: `--primary` token değeri DEĞİŞMEDİ (#368)', () => {
143
+ // Bu issue token'a DOKUNMAZ. Kök neden token değil, üstüne binen İKİNCİ katman.
144
+ it.each([
145
+ ['primary', 'dark', '210 40% 98%'],
146
+ ['primary-foreground', 'dark', '222.2 47.4% 1.2%'],
147
+ ['primary', 'light', '217.2 91.2% 51%'],
148
+ ])('--%s (%s) = %s', (name, blok, beklenen) => {
149
+ expect(tokenOku(name, blok as Blok)).toBe(beklenen)
150
+ })
151
+ })
152
+
153
+ // ════════════════════════════════════════════════════════════════════════════════
154
+ describe('#646 (ii) — tüketici override`ı artık EZİLMİYOR (asıl kapı)', () => {
155
+ it('Card: `bg-white/90` verildiğinde hiçbir `dark:bg-*` hayatta kalmıyor', () => {
156
+ const merged = cn(cardVariants({}), 'bg-white/90 backdrop-blur-md')
157
+ expect(darkArkaPlanlar(merged)).toEqual([])
158
+ expect(parcala(merged)).toContain('bg-white/90')
159
+ // token da elenmiş olmalı (tüketici AYNI grupta kazanır)
160
+ expect(parcala(merged)).not.toContain('bg-card')
161
+ })
162
+
163
+ it('Badge (varyantsız = `primary`): tüketici bg/text ikisi de kazanıyor', () => {
164
+ const merged = cn(badgeVariants({}), 'bg-emerald-500/20 text-emerald-300')
165
+ expect(darkArkaPlanlar(merged)).toEqual([])
166
+ expect(parcala(merged)).toContain('bg-emerald-500/20')
167
+ expect(parcala(merged)).toContain('text-emerald-300')
168
+ expect(parcala(merged)).not.toContain('bg-primary')
169
+ expect(parcala(merged)).not.toContain('text-primary-foreground')
170
+ })
171
+
172
+ it('RENDER: <Badge className="bg-emerald-500/20 text-emerald-300"> DOM sınıfında `dark:bg-*` yok', () => {
173
+ const { container } = render(
174
+ <Badge className="bg-emerald-500/20 text-emerald-300">Live</Badge>
175
+ )
176
+ const el = container.firstElementChild as HTMLElement
177
+ expect(darkArkaPlanlar(el.className)).toEqual([])
178
+ expect(parcala(el.className)).toContain('bg-emerald-500/20')
179
+ })
180
+
181
+ it('RENDER: <Card className="bg-white/90"> DOM sınıfında `dark:bg-*` yok', () => {
182
+ const { container } = render(<Card className="bg-white/90">içerik</Card>)
183
+ const el = container.firstElementChild as HTMLElement
184
+ expect(darkArkaPlanlar(el.className)).toEqual([])
185
+ expect(parcala(el.className)).toContain('bg-white/90')
186
+ })
187
+ })
188
+
189
+ // ════════════════════════════════════════════════════════════════════════════════
190
+ describe('#646 (i) — component kendi metnini artık EZMİYOR', () => {
191
+ it('Tabs `pills` aktif sekme: `dark:…text-white` KALDIRILDI', () => {
192
+ const pills = tabsTriggerVariants({ variant: 'pills' })
193
+ expect(parcala(pills)).not.toContain('dark:data-[state=active]:text-white')
194
+ expect(parcala(pills)).toContain('data-[state=active]:text-primary-foreground')
195
+ })
196
+
197
+ it('⚠️ Tabs `pills`: `dark:…bg-primary/90` BİLEREK KORUNDU (özgüllük kilidi)', () => {
198
+ // Kaldırılırsa `dark:bg-gray-800` (inaktif zemin) eşit özgüllükle kazanır ve
199
+ // aktif pill koyu temada 1.41:1'e düşer — #545'in "ihlal takası" tuzağı.
200
+ // Bu assert bilinçli kararı GÖRÜNÜR kılar; niyet değişirse burası düşer.
201
+ const pills = tabsTriggerVariants({ variant: 'pills' })
202
+ expect(parcala(pills)).toContain('dark:data-[state=active]:bg-primary/90')
203
+ expect(parcala(pills)).toContain('dark:bg-gray-800')
204
+ })
205
+
206
+ it('aktif pill / primary rozet çifti İKİ TEMADA da AA geçiyor', () => {
207
+ // dark: zemin `bg-primary/90` (korunan sınıf), metin `text-primary-foreground`
208
+ //
209
+ // ⚠️ ZEMİN AÇIKÇA YAZILIR (#544/#545 dersi): sınıf YARI SAYDAM olduğu için oran
210
+ // altındaki yüzeye bağlıdır. Kanonik koyu kart (#111827) üstünde 16.13; tabs
211
+ // docs sayfasında pill'in gerçek zemini rgb(224,226,229) çıktığı için orada
212
+ // tarayıcı 15.91 ölçtü. İkisi de AA'nın çok üstünde — fark yöntem farkı değil,
213
+ // yarı saydamlığın altındaki yüzeyin farkı.
214
+ const darkZemin = komposit(semanticRgb('primary', 'dark'), CARD_ACTUAL_DARK, 0.9)
215
+ const darkOran = kontrast(darkZemin, semanticRgb('primary-foreground', 'dark'))
216
+ expect(darkOran).toBeGreaterThan(AA_TEXT)
217
+ expect(darkOran).toBeCloseTo(16.13, 1)
218
+
219
+ // dark, rozet yolu: yarı saydamlık yok → tam `bg-primary`
220
+ const rozetOran = kontrast(semanticRgb('primary', 'dark'), semanticRgb('primary-foreground', 'dark'))
221
+ expect(rozetOran).toBeGreaterThan(AA_TEXT)
222
+ expect(rozetOran).toBeCloseTo(19.8, 1)
223
+
224
+ // light: DEĞİŞMEDİ — `dark:` sınıfları açık temada zaten uygulanmıyordu
225
+ const lightOran = kontrast(semanticRgb('primary', 'light'), semanticRgb('primary-foreground', 'light'))
226
+ expect(lightOran).toBeGreaterThan(AA_TEXT)
227
+ expect(lightOran).toBeCloseTo(4.7, 1)
228
+ })
229
+ })
230
+
231
+ // ════════════════════════════════════════════════════════════════════════════════
232
+ describe('#646 — kaynak çapaları (regresyon koruması)', () => {
233
+ it('card cva TABANI: `dark:bg-gray-900` yok, `bg-card` token`ı duruyor', () => {
234
+ const taban = yorumsuz(cardSrc).match(/const cardVariants = cva\(\s*"([^"]+)"/)?.[1]
235
+ expect(taban).toBeDefined()
236
+ expect(parcala(taban!)).not.toContain('dark:bg-gray-900')
237
+ expect(parcala(taban!)).toContain('bg-card')
238
+ expect(darkArkaPlanlar(taban!)).toEqual([])
239
+ })
240
+
241
+ it('badge `primary` dalı: `dark:bg-primary/90` yok, öneksiz `bg-primary` var', () => {
242
+ expect(yorumsuz(badgeSrc)).not.toContain('dark:bg-primary/90')
243
+ expect(yorumsuz(badgeSrc)).toContain('bg-primary text-primary-foreground')
244
+ })
245
+
246
+ it('tabs `pills` dalı: `dark:data-[state=active]:text-white` yok', () => {
247
+ expect(yorumsuz(tabsSrc)).not.toContain('dark:data-[state=active]:text-white')
248
+ })
249
+
250
+ it('KARDEŞ TARAMA KİLİDİ: free pakette cva TABANINDA `dark:bg-*` kalmadı', () => {
251
+ // #646'nın ekseni "cva TABANINDA dark:-scope'lu arka plan". Tarama sonucu tek
252
+ // ihlal card.tsx'teydi; burası yeni bir taban ihlalinin sessizce girmesini
253
+ // engeller. (Varyant katmanındaki `dark:bg-*` ayrı bir sınıf — bkz. rapor.)
254
+ const uiDizin = path.join(PKG, 'components/ui')
255
+ const ihlaller: string[] = []
256
+ for (const dosya of fs.readdirSync(uiDizin).filter((f) => f.endsWith('.tsx'))) {
257
+ const src = yorumsuz(fs.readFileSync(path.join(uiDizin, dosya), 'utf8'))
258
+ // cva( sonrası ilk argüman: `"..."` ya da `[ ... ]`
259
+ for (const m of src.matchAll(/\bcva\(\s*(\[[\s\S]*?\]|"[^"]*")/g)) {
260
+ if (/dark:(?:[a-z-]+:)*bg-/.test(m[1])) ihlaller.push(`${dosya}: ${m[1].slice(0, 80)}`)
261
+ }
262
+ }
263
+ expect(ihlaller).toEqual([])
264
+ })
265
+ })
266
+
267
+ // ════════════════════════════════════════════════════════════════════════════════
268
+ describe('#646 — NEGATİF KONTROL: kapsam dışı yollar BOZULMADI', () => {
269
+ it('Badge `outline` varyantı DEĞİŞMEDİ (`dark:bg-transparent` duruyor)', () => {
270
+ expect(parcala(badgeVariants({ variant: 'outline' }))).toContain('dark:bg-transparent')
271
+ })
272
+
273
+ it('Badge `destructive` #544 yolu bozulmadı (`bg-error-600`)', () => {
274
+ expect(parcala(badgeVariants({ variant: 'destructive' }))).toContain('bg-error-600')
275
+ })
276
+
277
+ it('Badge varsayılan görünümü: `text-primary-foreground` yolu korunuyor', () => {
278
+ const { container } = render(<Badge>Varsayılan</Badge>)
279
+ const cls = parcala((container.firstElementChild as HTMLElement).className)
280
+ expect(cls).toContain('bg-primary')
281
+ expect(cls).toContain('text-primary-foreground')
282
+ expect(cls).not.toContain('dark:text-white')
283
+ })
284
+
285
+ it('Card varsayılan görünümü: zemin token`a devredildi, kendi metniyle AA üstü', () => {
286
+ const { container } = render(<Card>içerik</Card>)
287
+ const cls = parcala((container.firstElementChild as HTMLElement).className)
288
+ expect(cls).toContain('bg-card')
289
+ // Koyu temada kartın kendi metni (`dark:text-gray-100`) `bg-card` üstünde
290
+ // ESKİSİNDEN İYİ: gray-900 üstünde 16.12 → bg-card üstünde 18.31 (ölçüldü).
291
+ const gray100: RGB = [243, 244, 246]
292
+ const oncekiOran = kontrast(CARD_ACTUAL_DARK, gray100)
293
+ const sonrakiOran = kontrast(semanticRgb('card', 'dark'), gray100)
294
+ expect(sonrakiOran).toBeGreaterThan(oncekiOran)
295
+ expect(sonrakiOran).toBeGreaterThan(AA_TEXT)
296
+ })
297
+ })
@@ -0,0 +1,113 @@
1
+ /**
2
+ * #661 — A1-A6 bundle, (A) a11y dilimi · free paket tarafı
3
+ *
4
+ * Kalem 2 · `file-upload` — gizli `<input type="file">` tüm bırakma alanını
5
+ * `opacity-0` ile kaplıyor. Klavyeyle Tab'landığında odak GERÇEKTEN oraya
6
+ * gidiyor ama görünür hiçbir iz yoktu (`focus-within` grep = 0). WCAG 2.4.7.
7
+ * Odak halkası girdinin kendisine verilemez (görünmez), sarmalayıcıya
8
+ * `focus-within` ile bağlanır.
9
+ *
10
+ * Kalem 7 · `rating` — `role="radiogroup"` sarmalayıcısı TEK tab durağı,
11
+ * `role="radio"` çocuklarının hepsi `tabIndex={-1}`. Ne roving-tabindex ne
12
+ * `aria-activedescendant` vardı: ok tuşlarıyla değer değişiyordu ama yardımcı
13
+ * teknolojiye hangi radyonun aktif olduğu HİÇ bildirilmiyordu.
14
+ *
15
+ * ÖLÇÜM DİSİPLİNİ: iddia kaynak metninden değil, render edilmiş DOM'dan okunur.
16
+ * `aria-activedescendant`'ın İŞARET ETTİĞİ elemanın gerçekten var olduğu da
17
+ * ölçülür — var olmayan bir id'ye işaret etmek kusurun kendisidir.
18
+ */
19
+ import * as React from 'react'
20
+ import { render, screen, fireEvent } from '@testing-library/react'
21
+ import '@testing-library/jest-dom'
22
+
23
+ import { FileUpload } from '../components/ui/file-upload'
24
+ import { Rating } from '../components/ui/rating'
25
+
26
+ /* ────────────────────────────────────────────────────────────────────────── *
27
+ * Kalem 2 · file-upload — gizli girdinin odak izi
28
+ * ────────────────────────────────────────────────────────────────────────── */
29
+ describe('#661-2 · FileUpload gizli girdisi odaklanınca görsel iz üretir', () => {
30
+ it('bırakma alanı sarmalayıcısı focus-within odak halkası sınıflarını taşır', () => {
31
+ const { container } = render(<FileUpload />)
32
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
33
+ expect(input).toBeInTheDocument()
34
+
35
+ // Girdi görünmez: kendi `focus-visible` halkası kullanıcıya HİÇBİR ŞEY göstermez.
36
+ expect(input.className).toContain('opacity-0')
37
+
38
+ const dropzone = input.parentElement as HTMLElement
39
+ // Kusurlu sürümde bu üç sınıfın ÜÇÜ DE yoktu.
40
+ expect(dropzone.className).toContain('focus-within:ring-2')
41
+ expect(dropzone.className).toContain('focus-within:ring-ring')
42
+ expect(dropzone.className).toContain('focus-within:ring-offset-2')
43
+ })
44
+
45
+ it('girdi gerçekten odaklanabilir — halka bağlandığı eleman odağı KAPSAR', () => {
46
+ const { container } = render(<FileUpload />)
47
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
48
+ input.focus()
49
+ expect(document.activeElement).toBe(input)
50
+ // `focus-within` sarmalayıcıda çalışabilmesi için odaklanan eleman onun
51
+ // İÇİNDE olmalı — jsdom CSS uygulamaz, ama kapsama ilişkisi ölçülebilir.
52
+ expect(input.parentElement).toContainElement(input)
53
+ })
54
+ })
55
+
56
+ /* ────────────────────────────────────────────────────────────────────────── *
57
+ * Kalem 7 · rating — aktif radyo yardımcı teknolojiye bildiriliyor mu?
58
+ * ────────────────────────────────────────────────────────────────────────── */
59
+ describe('#661-7 · Rating radiogroup aktif seçeneği duyurur', () => {
60
+ it('aria-activedescendant VAR ve var olan bir radyoyu işaret eder', () => {
61
+ render(<Rating defaultValue={3} aria-label="Score" />)
62
+ const group = screen.getByRole('radiogroup')
63
+
64
+ const active = group.getAttribute('aria-activedescendant')
65
+ expect(active).toBeTruthy()
66
+
67
+ const target = document.getElementById(active as string)
68
+ expect(target).not.toBeNull()
69
+ expect(target).toHaveAttribute('role', 'radio')
70
+ // Seçili değer 3 → işaret edilen radyo "checked" olmalı.
71
+ expect(target).toHaveAttribute('aria-checked', 'true')
72
+ })
73
+
74
+ it('ok tuşuyla değer değişince aktif seçenek de KAYAR', () => {
75
+ render(<Rating defaultValue={3} aria-label="Score" />)
76
+ const group = screen.getByRole('radiogroup')
77
+ const once = group.getAttribute('aria-activedescendant')
78
+
79
+ fireEvent.keyDown(group, { key: 'ArrowRight' })
80
+
81
+ const after = group.getAttribute('aria-activedescendant')
82
+ expect(after).not.toBe(once)
83
+ const target = document.getElementById(after as string)
84
+ expect(target).toHaveAttribute('aria-checked', 'true')
85
+ expect(target).toHaveAttribute('aria-label', '4 stars')
86
+ })
87
+
88
+ it('seçim yokken ARIA gereği İLK seçenek aktif kabul edilir (kırık id üretilmez)', () => {
89
+ render(<Rating aria-label="Score" />)
90
+ const group = screen.getByRole('radiogroup')
91
+ const active = group.getAttribute('aria-activedescendant')
92
+ // Kusurlu bir düzeltme burada `-option-0` üretip HİÇ VAR OLMAYAN bir id'ye
93
+ // işaret edebilirdi; ölçüm tam olarak bunu engelliyor.
94
+ const target = document.getElementById(active as string)
95
+ expect(target).not.toBeNull()
96
+ expect(target).toHaveAttribute('aria-label', '1 star')
97
+ })
98
+
99
+ it('salt-gösterim modunda aria-activedescendant HİÇ basılmaz (odak durağı yok)', () => {
100
+ render(<Rating value={4} readOnly aria-label="Score" />)
101
+ const group = screen.getByRole('radiogroup')
102
+ expect(group).not.toHaveAttribute('aria-activedescendant')
103
+ })
104
+
105
+ it('half hassasiyette id ondalık değerle çakışmaz', () => {
106
+ render(<Rating defaultValue={2.5} precision="half" aria-label="Score" />)
107
+ const group = screen.getByRole('radiogroup')
108
+ const target = document.getElementById(
109
+ group.getAttribute('aria-activedescendant') as string
110
+ )
111
+ expect(target).toHaveAttribute('aria-label', '2.5 stars')
112
+ })
113
+ })