@moontra/moonui 6.17.0 → 6.19.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,375 @@
1
+ /**
2
+ * #389 — Slider `secondary` range↔track kontrast değişmezi.
3
+ *
4
+ * BULGU (#372'nin birebir ardılı, PR #385'in "yan bulgu" notundan): slider'ın
5
+ * range'i `bg-secondary`, track'i `bg-secondary/20` — ikisi de AYNI yüzey
6
+ * token'ına basıyor. `--secondary` light'ta rgb(235,239,245), dark'ta
7
+ * rgb(2,8,23); %20 alfa track ile tam dolgu iki temada da ~1.0 oranında kalıyor,
8
+ * slider'ın DOLU kısmı hiç görünmüyordu. axe bunu yakalamaz: `color-contrast`
9
+ * yalnız METİN öğelerini ölçer.
10
+ *
11
+ * DÜZELTME: range `bg-[rgb(var(--secondary-500))]` (tokens.css slate skalası).
12
+ * `bg-secondary-foreground` BİLEREK seçilmedi — light'ta --primary ile aynı
13
+ * hue/sat'ta ΔL=%2, dark'ta birebir --primary değeri: varyant primary klonuna
14
+ * dönerdi. Track `bg-secondary/20` KALDI (#372 seçenek 1; track↔zemin ayrışması
15
+ * ayrı token-ekseni bulgusudur, kapsam dışı).
16
+ *
17
+ * ZEMİN DENKLİĞİ (kendi doğrulaması, #389): slider'ın ölçüm zeminleri
18
+ * progress'inkiyle AYNI kümedir — track her iki component'te de `bg-secondary/20`
19
+ * alfa katmanı olduğundan bileşke rengi altındaki yüzeye bağlıdır; slider'ın
20
+ * sardığı `.moonui-theme` sınıfı paket CSS'inde HİÇBİR token'ı yeniden
21
+ * tanımlamaz (yalnız işaretleyici sınıf — `grep -rn '\.moonui-theme'
22
+ * packages/moonui/src/styles/` boş döner). Dolayısıyla #372'deki kademe seçimi
23
+ * (400/500/600 elemesi) slider için de birebir geçerlidir ve aşağıda yeniden
24
+ * hesaplanarak kilitlenir.
25
+ *
26
+ * Kabul kriteri (issue #389): range↔track kontrastı light ve dark temada ≥3:1
27
+ * (WCAG 1.4.11 non-text eşiği).
28
+ *
29
+ * Hesap yöntemi progress-secondary-contrast.test.ts / primary-subtle-token.test.ts
30
+ * ile birebir aynıdır; oradaki "hesaplayıcı doğru mu?" çapası burada da var.
31
+ */
32
+ import * as React from 'react'
33
+ import { render } from '@testing-library/react'
34
+ import '@testing-library/jest-dom'
35
+ import * as fs from 'fs'
36
+ import * as path from 'path'
37
+ import { Slider } from '../slider'
38
+
39
+ const KOK = path.resolve(__dirname, '../../..')
40
+ const tokens = fs.readFileSync(path.join(KOK, 'styles/tokens.css'), 'utf8')
41
+
42
+ /** "H S% L%" biçimindeki token değerini oku (light = :root, dark = .dark bloğu) */
43
+ function tokenOku(ad: string, blok: 'light' | 'dark'): string | null {
44
+ const kaynak =
45
+ blok === 'dark'
46
+ ? tokens.slice(tokens.indexOf('.dark'))
47
+ : tokens.slice(0, tokens.indexOf('.dark'))
48
+ return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
49
+ }
50
+
51
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
52
+ h /= 360
53
+ s /= 100
54
+ l /= 100
55
+ const f = (n: number) => {
56
+ const k = (n + h * 12) % 12
57
+ const a = s * Math.min(l, 1 - l)
58
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
59
+ }
60
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
61
+ }
62
+
63
+ const hslTokenRgb = (v: string): [number, number, number] => {
64
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
65
+ return hslToRgb(h, s, l)
66
+ }
67
+
68
+ /**
69
+ * Skala token'ları ("--secondary-500: 100 116 139") HSL DEĞİL, RGB üçlüsüdür
70
+ * (slate-500 = rgb(100,116,139) ile birebir; component'te bu yüzden
71
+ * `bg-[rgb(var(--secondary-500))]` kompozisyonu kullanılır, hsl() değil).
72
+ */
73
+ const rgbTokenRgb = (v: string): [number, number, number] => {
74
+ const p = v.split(/\s+/).map((x) => parseFloat(x))
75
+ return [p[0], p[1], p[2]]
76
+ }
77
+
78
+ function bagilParlaklik([r, g, b]: [number, number, number]): number {
79
+ const c = (v: number) => {
80
+ const x = v / 255
81
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
82
+ }
83
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
84
+ }
85
+
86
+ function rgbKontrast(a: [number, number, number], b: [number, number, number]): number {
87
+ const la = bagilParlaklik(a)
88
+ const lb = bagilParlaklik(b)
89
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
90
+ return (hi + 0.05) / (lo + 0.05)
91
+ }
92
+
93
+ /** `bg-secondary/20` gibi alfa katmanının zemin üstündeki bileşke rengi */
94
+ function alfaBileske(
95
+ ust: [number, number, number],
96
+ alfa: number,
97
+ zemin: [number, number, number]
98
+ ): [number, number, number] {
99
+ return [
100
+ Math.round(alfa * ust[0] + (1 - alfa) * zemin[0]),
101
+ Math.round(alfa * ust[1] + (1 - alfa) * zemin[1]),
102
+ Math.round(alfa * ust[2] + (1 - alfa) * zemin[2]),
103
+ ]
104
+ }
105
+
106
+ /**
107
+ * Docs kabuğunda ölçülen GERÇEK koyu kart zemini (#111827 = rgb(17,24,39)) —
108
+ * #284/#368 konvansiyonu: kontrast, --background'ın yanında bu gerçek yüzeye
109
+ * göre de ölçülür (tokens.css --input notu).
110
+ */
111
+ const KART_GERCEK_DARK: [number, number, number] = [17, 24, 39]
112
+
113
+ /** Tema başına range (secondary-500) ve track (secondary@%20 ∘ zemin) renkleri */
114
+ function temaRenkleri(blok: 'light' | 'dark') {
115
+ const dolgu = rgbTokenRgb(tokenOku('secondary-500', 'light')!) // skala yalnız :root'ta — iki temada aynı
116
+ const secondary = hslTokenRgb(tokenOku('secondary', blok)!)
117
+ const background = hslTokenRgb(tokenOku('background', blok)!)
118
+ const card = hslTokenRgb(tokenOku('card', blok)!)
119
+ const zeminler: Array<[string, [number, number, number]]> = [
120
+ ['--background', background],
121
+ ['--card', card],
122
+ ]
123
+ if (blok === 'dark') zeminler.push(['gerçek kart #111827', KART_GERCEK_DARK])
124
+ return { dolgu, secondary, zeminler }
125
+ }
126
+
127
+ /** Slider DOM'u: kök > .moonui-theme > track > range (thumb'lar track'ten SONRA) */
128
+ function parcalar(container: HTMLElement) {
129
+ const kabuk = container.querySelector('.moonui-theme') as HTMLElement
130
+ expect(kabuk).not.toBeNull()
131
+ const track = kabuk.firstElementChild as HTMLElement
132
+ const range = track.firstElementChild as HTMLElement
133
+ expect(range).not.toBeNull()
134
+ return { track, range }
135
+ }
136
+
137
+ describe('#389 slider secondary range↔track kontrastı', () => {
138
+ it('hesaplayıcı DOĞRU — bilinen bir çift belgelenen oranı veriyor', () => {
139
+ // Sessiz-yeşil koruması: `--info-subtle` çifti tokens.css'te 9.52:1 diye
140
+ // belgelenmiş; hesaplayıcı bozulursa bu çapa kırmızı olur.
141
+ const oran = rgbKontrast(
142
+ hslTokenRgb(tokenOku('info-subtle', 'light')!),
143
+ hslTokenRgb(tokenOku('info-subtle-foreground', 'light')!)
144
+ )
145
+ expect(oran).toBeGreaterThan(9.3)
146
+ expect(oran).toBeLessThan(9.7)
147
+ })
148
+
149
+ it('--secondary-500 tanımlı ve RGB üçlüsü olarak ayrıştırılabiliyor', () => {
150
+ const ham = tokenOku('secondary-500', 'light')
151
+ expect(ham).toBeTruthy()
152
+ const rgb = rgbTokenRgb(ham!)
153
+ rgb.forEach((kanal) => {
154
+ expect(Number.isFinite(kanal)).toBe(true)
155
+ expect(kanal).toBeGreaterThanOrEqual(0)
156
+ expect(kanal).toBeLessThanOrEqual(255)
157
+ })
158
+ })
159
+
160
+ describe('kaynak sözleşmesi: range ile track FARKLI token’lara basıyor', () => {
161
+ const kaynak = fs.readFileSync(path.join(KOK, 'components/ui/slider.tsx'), 'utf8')
162
+
163
+ it('track secondary sınıfı `bg-secondary/20` (değişmedi)', () => {
164
+ expect(kaynak).toMatch(/secondary:\s*"bg-secondary\/20"/)
165
+ })
166
+
167
+ it('range secondary sınıfı `bg-[rgb(var(--secondary-500))]` (artık --secondary DEĞİL)', () => {
168
+ expect(kaynak).toMatch(/secondary:\s*"bg-\[rgb\(var\(--secondary-500\)\)\]"/)
169
+ })
170
+
171
+ it('#407: thumb secondary sınıfı `border-[rgb(var(--secondary-500))] bg-background`', () => {
172
+ // AYRI EKSEN (aşağıdaki "#407 thumb kenarlığı" bloğuna bakınız): #389 yalnız
173
+ // range↔track eksenini kapsıyordu, tutamacın kenarlığı bilerek dışarıda kalmıştı.
174
+ expect(kaynak).toMatch(
175
+ /secondary:\s*"border-\[rgb\(var\(--secondary-500\)\)\] bg-background"/
176
+ )
177
+ expect(kaynak).not.toMatch(/secondary:\s*"border-secondary bg-background"/)
178
+ })
179
+
180
+ it('zemin denkliği: `.moonui-theme` paket CSS’inde token yeniden tanımlamıyor', () => {
181
+ // Bu testin varlık sebebi: kontrast hesabı track'in altındaki yüzeyin
182
+ // --background/--card olduğunu VARSAYAR. Biri `.moonui-theme`i token
183
+ // kapsamına çevirirse (ör. `.moonui-theme { --secondary: ... }`) hesap
184
+ // sessizce yanlışlanır — o an bu test kırmızı olur.
185
+ const stilDizini = path.join(KOK, 'styles')
186
+ const stiller = fs
187
+ .readdirSync(stilDizini)
188
+ .filter((f) => f.endsWith('.css'))
189
+ .map((f) => fs.readFileSync(path.join(stilDizini, f), 'utf8'))
190
+ .join('\n')
191
+ expect(stiller).not.toMatch(/\.moonui-theme[^{]*\{[^}]*--secondary/)
192
+ })
193
+ })
194
+
195
+ describe('render sözleşmesi: <Slider /> secondary', () => {
196
+ it('rangeVariant="secondary" DOM’a yeni token’la iniyor, track eski token’da kalıyor', () => {
197
+ const { container } = render(
198
+ React.createElement(Slider, { trackVariant: 'secondary', rangeVariant: 'secondary' })
199
+ )
200
+ const { track, range } = parcalar(container)
201
+ expect(track).toHaveClass('bg-secondary/20')
202
+ expect(range.className).toContain('bg-[rgb(var(--secondary-500))]')
203
+ expect(range.className).not.toMatch(/(?:^|\s)bg-secondary(?:\s|$)/)
204
+ })
205
+
206
+ it('thumbVariant düşüşü KORUNUYOR — tek prop ile track+range ikisi de secondary', () => {
207
+ // `trackVariant || thumbVariant || "default"` ve
208
+ // `rangeVariant || thumbVariant || "primary"` düşüşleri değişmedi;
209
+ // en yaygın kullanım (<Slider thumbVariant="secondary" />) bu yoldan geçer.
210
+ const { container } = render(React.createElement(Slider, { thumbVariant: 'secondary' }))
211
+ const { track, range } = parcalar(container)
212
+ expect(track).toHaveClass('bg-secondary/20')
213
+ expect(range.className).toContain('bg-[rgb(var(--secondary-500))]')
214
+ })
215
+
216
+ it('rangeVariant açık verildiğinde thumbVariant’ı hâlâ eziyor', () => {
217
+ const { container } = render(
218
+ React.createElement(Slider, { thumbVariant: 'secondary', rangeVariant: 'success' })
219
+ )
220
+ const { range } = parcalar(container)
221
+ expect(range.className).toContain('bg-success')
222
+ expect(range.className).not.toContain('bg-[rgb(var(--secondary-500))]')
223
+ })
224
+
225
+ it('diğer varyantlar ETKİLENMEDİ — primary range hâlâ `bg-primary`', () => {
226
+ const { container } = render(React.createElement(Slider, { rangeVariant: 'primary' }))
227
+ const { range } = parcalar(container)
228
+ expect(range.className).toContain('bg-primary')
229
+ expect(range.className).not.toContain('secondary')
230
+ })
231
+ })
232
+
233
+ describe.each(['light', 'dark'] as const)('%s tema — kabul kriteri ≥3:1 (WCAG 1.4.11)', (blok) => {
234
+ it('YENİ range (secondary-500), track’in her olası zemindeki bileşkesine ≥3:1', () => {
235
+ // Ölçülen değerler: light 4.63 (--background=--card=beyaz) · dark 4.23
236
+ // (--background=--card) / 3.83 (gerçek kart #111827).
237
+ const { dolgu, secondary, zeminler } = temaRenkleri(blok)
238
+ for (const [ad, zemin] of zeminler) {
239
+ const track = alfaBileske(secondary, 0.2, zemin)
240
+ const oran = rgbKontrast(dolgu, track)
241
+ expect({ zemin: ad, oran }).toEqual({ zemin: ad, oran: expect.any(Number) })
242
+ expect(oran).toBeGreaterThanOrEqual(3.0)
243
+ }
244
+ })
245
+
246
+ it('ESKİ range (bg-secondary) aynı ölçümde başarısızdı — bug’ın belgesi', () => {
247
+ // Düzeltmenin GEREKLİLİĞİNİ sabitler: eski değer üç zeminde de eşiğin
248
+ // çok altındaydı (light 1.12 · dark 1.01 · gerçek kart 1.10).
249
+ const { secondary, zeminler } = temaRenkleri(blok)
250
+ for (const [, zemin] of zeminler) {
251
+ const track = alfaBileske(secondary, 0.2, zemin)
252
+ expect(rgbKontrast(secondary, track)).toBeLessThan(3.0)
253
+ }
254
+ })
255
+ })
256
+
257
+ describe('seçim gerekçesi: 500, iki temada birden geçen TEK skala kademesi', () => {
258
+ // Range her iki temada AYNI renk olduğundan parlaklığı [~0.107, ~0.289]
259
+ // bandında kalmalı: daha açık kademe light track'e (near-white), daha koyu
260
+ // kademe dark track'e (near-black) yetişemiyor. Komşu kademeler bunu kanıtlar.
261
+ it('secondary-400 light temada eşiğin ALTINDA kalır (~2.50)', () => {
262
+ const k400 = rgbTokenRgb(tokenOku('secondary-400', 'light')!)
263
+ const { secondary, zeminler } = temaRenkleri('light')
264
+ const track = alfaBileske(secondary, 0.2, zeminler[0][1])
265
+ expect(rgbKontrast(k400, track)).toBeLessThan(3.0)
266
+ })
267
+
268
+ it('secondary-600 dark temada eşiğin ALTINDA kalır (~2.66)', () => {
269
+ const k600 = rgbTokenRgb(tokenOku('secondary-600', 'light')!)
270
+ const { secondary, zeminler } = temaRenkleri('dark')
271
+ const track = alfaBileske(secondary, 0.2, zeminler[0][1])
272
+ expect(rgbKontrast(k600, track)).toBeLessThan(3.0)
273
+ })
274
+
275
+ it('secondary-500 üç zeminde de geçen TEK kademe — komşuları elenirken o kalır', () => {
276
+ // Tek testte tüm skalayı tarayıp "geçen kademe kümesi = {500}" iddiasını
277
+ // kilitler; ileride skala değerleri değişirse burası kırmızı olur.
278
+ const kademeler = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
279
+ const gecenler = kademeler.filter((k) => {
280
+ const renk = rgbTokenRgb(tokenOku(`secondary-${k}`, 'light')!)
281
+ return (['light', 'dark'] as const).every((blok) => {
282
+ const { secondary, zeminler } = temaRenkleri(blok)
283
+ return zeminler.every(
284
+ ([, zemin]) => rgbKontrast(renk, alfaBileske(secondary, 0.2, zemin)) >= 3.0
285
+ )
286
+ })
287
+ })
288
+ expect(gecenler).toEqual([500])
289
+ })
290
+
291
+ it('secondary-foreground kontrastı GEÇERDİ ama primary klonu üretirdi — kilit', () => {
292
+ // Bu test "neden secondary-foreground değil?" kararının dayanağını sabitler:
293
+ // light'ta --secondary-foreground ile --primary aynı hue/sat'ta ΔL=%2
294
+ // (fill↔fill 1.07), dark'ta birebir aynı değer. Varyant kimliği kaybolurdu.
295
+ const lightOran = rgbKontrast(
296
+ hslTokenRgb(tokenOku('secondary-foreground', 'light')!),
297
+ hslTokenRgb(tokenOku('primary', 'light')!)
298
+ )
299
+ expect(lightOran).toBeLessThan(1.3) // ayırt edilemez — near-identical
300
+ expect(tokenOku('secondary-foreground', 'dark')).toBe(tokenOku('primary', 'dark'))
301
+ })
302
+ })
303
+ })
304
+
305
+ /**
306
+ * #407 — thumb kenarlığı: range↔track'ten FARKLI bir kontrast ekseni.
307
+ *
308
+ * #389 yalnız range↔track eksenini kapsadığı için tutamacın kenarlığı bilerek
309
+ * dışarıda bırakılmıştı (PR #406, "yan bulgu 2"): `border-secondary` + `bg-background`
310
+ * dolgu → kenarlık `--background`/`--card` üstünde light 1.15 · dark 1.01 oranında
311
+ * kalıyor, secondary slider'ın tutamacı zeminde kayboluyordu.
312
+ *
313
+ * Burada ölçülen eksen bileşen↔sayfa-zemini'dir; alfa katmanı YOKTUR (tutamacın
314
+ * dolgusu doğrudan `bg-background`, yani zeminin kendisi). Non-text UI bileşeni
315
+ * olduğundan WCAG 1.4.11 ≥3:1 ister.
316
+ *
317
+ * Aynı skala taraması bu eksende de tek kademe bırakır: `--secondary-500`.
318
+ * Pro paketinde ikizi: packages/moonui-pro/.../slider-secondary-contrast.test.tsx
319
+ */
320
+ describe('#407 slider secondary thumb kenarlığı ↔ zemin kontrastı', () => {
321
+ /** Tutamaç track'in KARDEŞİDİR (track'ten sonra render edilir) */
322
+ const thumbBul = (container: HTMLElement) => {
323
+ const thumb = container.querySelector('[role="slider"]') as HTMLElement
324
+ expect(thumb).not.toBeNull()
325
+ return thumb
326
+ }
327
+
328
+ it('render sözleşmesi: thumbVariant="secondary" kenarlığı yeni token’la iniyor', () => {
329
+ const { container } = render(React.createElement(Slider, { thumbVariant: 'secondary' }))
330
+ const thumb = thumbBul(container)
331
+ expect(thumb.className).toContain('border-[rgb(var(--secondary-500))]')
332
+ expect(thumb.className).toContain('bg-background')
333
+ expect(thumb.className).not.toMatch(/(?:^|\s)border-secondary(?:\s|$)/)
334
+ })
335
+
336
+ it('render sözleşmesi: diğer varyantların kenarlığı ETKİLENMEDİ', () => {
337
+ const { container } = render(React.createElement(Slider, { thumbVariant: 'primary' }))
338
+ const thumb = thumbBul(container)
339
+ expect(thumb.className).toContain('border-primary')
340
+ expect(thumb.className).not.toContain('secondary')
341
+ })
342
+
343
+ describe.each(['light', 'dark'] as const)('%s tema — kabul kriteri ≥3:1', (blok) => {
344
+ it('YENİ kenarlık (secondary-500) her zemine ≥3:1', () => {
345
+ // Ölçülen: light 4.76 · dark 4.23 (gerçek kart #111827 üstünde 3.73).
346
+ const { dolgu, zeminler } = temaRenkleri(blok)
347
+ for (const [ad, zemin] of zeminler) {
348
+ const oran = rgbKontrast(dolgu, zemin)
349
+ expect({ zemin: ad, gecti: oran >= 3.0 }).toEqual({ zemin: ad, gecti: true })
350
+ }
351
+ })
352
+
353
+ it('ESKİ kenarlık (border-secondary) aynı ölçümde başarısızdı — bug’ın belgesi', () => {
354
+ // light 1.15 · dark 1.01 · gerçek kart 1.13 — tutamaç fiilen görünmüyordu.
355
+ const { secondary, zeminler } = temaRenkleri(blok)
356
+ for (const [, zemin] of zeminler) {
357
+ expect(rgbKontrast(secondary, zemin)).toBeLessThan(3.0)
358
+ }
359
+ })
360
+ })
361
+
362
+ it('seçim gerekçesi: bu eksende de geçen kademe kümesi tam olarak {500}', () => {
363
+ // Range ekseniyle AYNI kademede buluşması tesadüf değil, ölçüm sonucudur:
364
+ // alfa katmanı olmayan zemin, %20 alfa track'e çok yakın parlaklıkta olduğundan
365
+ // geçerli bant neredeyse aynıdır. Yine de bağımsız olarak taranır.
366
+ const kademeler = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
367
+ const gecenler = kademeler.filter((k) => {
368
+ const renk = rgbTokenRgb(tokenOku(`secondary-${k}`, 'light')!)
369
+ return (['light', 'dark'] as const).every((blok) =>
370
+ temaRenkleri(blok).zeminler.every(([, zemin]) => rgbKontrast(renk, zemin) >= 3.0)
371
+ )
372
+ })
373
+ expect(gecenler).toEqual([500])
374
+ })
375
+ })
@@ -135,30 +135,81 @@ export function DataTableBasic<TData, TValue>({
135
135
  {table.getHeaderGroups().map((headerGroup) => (
136
136
  <TableRow key={headerGroup.id}>
137
137
  {headerGroup.headers.map((header) => {
138
+ /* #366: mevcut ikon guard'ı ile aynı koşul — `enableSorting`
139
+ kapalıyken başlık tamamen etkileşimsizdir (eskiden onClick
140
+ ve cursor-pointer yine de kalıyordu ama sortedRowModel
141
+ olmadığından hiçbir görünür etki üretmiyordu). */
142
+ const canSort = enableSorting && header.column.getCanSort();
143
+ const sorted = header.column.getIsSorted();
144
+ const headerDef = header.column.columnDef.header;
138
145
  return (
139
- <TableHead key={header.id}>
146
+ <TableHead
147
+ key={header.id}
148
+ /* #366: sıralama durumu ekran okuyucuya YANSIMIYORDU
149
+ (dosyada `aria-sort` grep'i 0 idi) — yalnız görsel ok
150
+ ikonu değişiyordu. Sıralanamayan sütunda öznitelik hiç
151
+ basılmaz. table.tsx'te #321, pro DataTable'da #291 ile
152
+ aynı standart. */
153
+ aria-sort={
154
+ canSort
155
+ ? sorted === "asc"
156
+ ? "ascending"
157
+ : sorted === "desc"
158
+ ? "descending"
159
+ : "none"
160
+ : undefined
161
+ }
162
+ >
140
163
  {header.isPlaceholder ? null : (
141
164
  <div
142
165
  className={cn(
143
166
  "flex items-center gap-2",
144
- header.column.getCanSort() && "cursor-pointer select-none"
167
+ canSort && "cursor-pointer select-none"
145
168
  )}
146
- onClick={header.column.getToggleSortingHandler()}
169
+ /* Fare kolaylığı: başlığın tamamı tıklanabilir kalır.
170
+ Klavye yolu aşağıdaki GERÇEK <button>'dan geçer —
171
+ bu div'e role="button" taklidi VERMİYORUZ çünkü
172
+ başlık içeriği tüketiciden gelir (flexRender +
173
+ `createSortableHeader` zaten Button render eder)
174
+ ve rol taklidi buton-içinde-buton üretirdi; aynı
175
+ hata pro DataTable'da #345'te düzeltilmişti. */
176
+ onClick={
177
+ canSort
178
+ ? header.column.getToggleSortingHandler()
179
+ : undefined
180
+ }
147
181
  >
148
182
  {flexRender(
149
183
  header.column.columnDef.header,
150
184
  header.getContext()
151
185
  )}
152
- {enableSorting && header.column.getCanSort() && (
153
- <div className="ml-auto">
154
- {header.column.getIsSorted() === "desc" ? (
186
+ {canSort && (
187
+ /* #366: sıralama kontrolü yalnız fareyle çalışıyordu
188
+ (dosyada `tabIndex`/`onKeyDown` grep'i 0 idi).
189
+ Gerçek <button> ile Tab + Enter/Space native
190
+ gelir; görünür odak focus-visible ring ile. */
191
+ <button
192
+ type="button"
193
+ onClick={(event) => {
194
+ // Sarmalayıcı div de sıralıyor; çifte tetiklenmesin
195
+ event.stopPropagation();
196
+ header.column.getToggleSortingHandler()?.(event);
197
+ }}
198
+ aria-label={`Sort by ${
199
+ typeof headerDef === "string"
200
+ ? headerDef
201
+ : header.column.id
202
+ }`}
203
+ className="ml-auto inline-flex items-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
204
+ >
205
+ {sorted === "desc" ? (
155
206
  <ArrowDown className="h-4 w-4" />
156
- ) : header.column.getIsSorted() === "asc" ? (
207
+ ) : sorted === "asc" ? (
157
208
  <ArrowUp className="h-4 w-4" />
158
209
  ) : (
159
210
  <ArrowUpDown className="h-4 w-4 opacity-50" />
160
211
  )}
161
- </div>
212
+ </button>
162
213
  )}
163
214
  </div>
164
215
  )}
@@ -81,6 +81,35 @@ const formatFileSize = (bytes: number): string => {
81
81
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
82
82
  }
83
83
 
84
+ /**
85
+ * Tek bir accept token'ını HTML `input[accept]` semantiğiyle dosyayla eşler (#378):
86
+ * - Uzantı token'ı (`.pdf`) → dosya ADI son ekiyle (harf duyarsız)
87
+ * - Wildcard token'ı (`image/*`) → MIME type prefix'iyle
88
+ * - Düz MIME token'ı (`application/pdf`) → MIME type ile birebir
89
+ *
90
+ * Eski mantık uzantı token'larını da `File.type` (MIME) ile karşılaştırıyordu;
91
+ * `.pdf` hiçbir MIME değerine eşit olamayacağı için `accept=".pdf"` altında
92
+ * batch komple reddediliyordu.
93
+ */
94
+ const matchesAcceptToken = (file: File, token: string): boolean => {
95
+ const normalized = token.trim().toLowerCase()
96
+ if (!normalized) return false
97
+ if (normalized === '*' || normalized === '*/*') return true
98
+ if (normalized.startsWith('.')) {
99
+ return file.name.toLowerCase().endsWith(normalized)
100
+ }
101
+ if (normalized.endsWith('/*')) {
102
+ return file.type.toLowerCase().startsWith(normalized.slice(0, -1))
103
+ }
104
+ return file.type.toLowerCase() === normalized
105
+ }
106
+
107
+ const isFileAccepted = (file: File, accept: string): boolean => {
108
+ const trimmed = accept.trim()
109
+ if (trimmed === '' || trimmed === '*') return true
110
+ return trimmed.split(',').some(token => matchesAcceptToken(file, token))
111
+ }
112
+
84
113
  const FileItem = ({
85
114
  uploadedFile,
86
115
  onRemove
@@ -188,13 +217,7 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
188
217
  return `File size exceeds ${formatFileSize(maxSize)}`
189
218
  }
190
219
 
191
- if (accept !== "*" && !accept.split(',').some(type => {
192
- const cleanType = type.trim()
193
- if (cleanType.includes('*')) {
194
- return file.type.startsWith(cleanType.replace('*', ''))
195
- }
196
- return file.type === cleanType
197
- })) {
220
+ if (!isFileAccepted(file, accept)) {
198
221
  return `File type not supported`
199
222
  }
200
223
 
@@ -241,6 +264,11 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
241
264
  setUploadedFiles(prev => [...prev, ...newUploadedFiles])
242
265
 
243
266
  // Simulate upload progress
267
+ // Timer'lar toplanır ki onUpload reddederse simülasyon durdurulabilsin;
268
+ // eskiden interval'ler yaşamaya devam ediyor ve son tick, catch'in yazdığı
269
+ // 'error' state'ini ezip 'success'/"Done" gösteriyordu (#378).
270
+ const progressIntervals = new Map<string, ReturnType<typeof setInterval>>()
271
+
244
272
  for (const uploadedFile of newUploadedFiles) {
245
273
  let progress = 0
246
274
  const interval = setInterval(() => {
@@ -248,24 +276,28 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
248
276
  if (progress >= 100) {
249
277
  progress = 100
250
278
  clearInterval(interval)
251
-
252
- setUploadedFiles(prev =>
253
- prev.map(f =>
254
- f.id === uploadedFile.id
279
+ progressIntervals.delete(uploadedFile.id)
280
+
281
+ setUploadedFiles(prev =>
282
+ prev.map(f =>
283
+ // 'error'a düşmüş dosya simülasyonca geri 'success' yapılamaz —
284
+ // error nihai durumdur (#378)
285
+ f.id === uploadedFile.id && f.status === 'uploading'
255
286
  ? { ...f, progress: 100, status: 'success' as const }
256
287
  : f
257
288
  )
258
289
  )
259
290
  } else {
260
- setUploadedFiles(prev =>
261
- prev.map(f =>
262
- f.id === uploadedFile.id
291
+ setUploadedFiles(prev =>
292
+ prev.map(f =>
293
+ f.id === uploadedFile.id && f.status === 'uploading'
263
294
  ? { ...f, progress }
264
295
  : f
265
296
  )
266
297
  )
267
298
  }
268
299
  }, 200)
300
+ progressIntervals.set(uploadedFile.id, interval)
269
301
  }
270
302
 
271
303
  // Call onUpload if provided
@@ -277,8 +309,11 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
277
309
  await onUpload(newUploadedFiles)
278
310
  } catch (error) {
279
311
  console.error('Upload failed:', error)
280
- setUploadedFiles(prev =>
281
- prev.map(f =>
312
+ // Reddetme anında bu batch'in simülasyon timer'ları temizlenir (#378)
313
+ progressIntervals.forEach(handle => clearInterval(handle))
314
+ progressIntervals.clear()
315
+ setUploadedFiles(prev =>
316
+ prev.map(f =>
282
317
  newUploadedFiles.some(nf => nf.id === f.id)
283
318
  ? { ...f, status: 'error' as const }
284
319
  : f
@@ -69,7 +69,20 @@ const progressIndicatorVariants = cva(
69
69
  variant: {
70
70
  default: "bg-foreground",
71
71
  primary: "bg-primary",
72
- secondary: "bg-secondary",
72
+ // #372: dolgu `bg-secondary` idi — track (`bg-secondary/20`) ile AYNI
73
+ // yüzey token'ına basıyordu; %20 alfa track ile tam dolgu iki temada da
74
+ // ~1.0 oranında kalıyor, bar görünmüyordu (light rgb(235,239,245) /
75
+ // dark rgb(2,8,23)). `bg-secondary-foreground` BİLEREK seçilmedi:
76
+ // light'ta --primary ile aynı hue/sat'ta yalnız ΔL=%2 (49 vs 51,
77
+ // fill↔fill 1.07), dark'ta birebir --primary değeri (210 40% 98%) —
78
+ // secondary varyantı primary'nin klonuna dönerdi. `--secondary-500`
79
+ // (tokens.css slate skalası, RGB üçlüsü) iki temada da track'e ≥3:1
80
+ // (WCAG 1.4.11) veren TEK kademe: light 4.63, dark 4.23 (gerçek kart
81
+ // #111827 üstünde 3.83; 400 light'ta 2.50 ✗, 600 dark'ta 2.66 ✗).
82
+ // Skala :root'ta tanımlı olduğundan tema değişiminde sabittir;
83
+ // tüketici --secondary-500'ü ezerek özelleştirebilir.
84
+ // Regresyon: __tests__/progress-secondary-contrast.test.ts
85
+ secondary: "bg-[rgb(var(--secondary-500))]",
73
86
  success: "bg-success",
74
87
  warning: "bg-warning",
75
88
  error: "bg-error",
@@ -58,7 +58,20 @@ const sliderRangeVariants = cva(
58
58
  variant: {
59
59
  default: "bg-foreground",
60
60
  primary: "bg-primary",
61
- secondary: "bg-secondary",
61
+ // #389 (#372'nin birebir ardılı): range `bg-secondary` idi — track'in
62
+ // (`bg-secondary/20`) AYNI yüzey token'ına basıyordu; %20 alfa track ile
63
+ // tam dolgu iki temada da ~1.0 oranında kalıyor, dolu kısım görünmüyordu
64
+ // (light rgb(235,239,245) → 1.12 · dark rgb(2,8,23) → 1.01).
65
+ // `bg-secondary-foreground` BİLEREK seçilmedi: light'ta --primary ile aynı
66
+ // hue/sat'ta yalnız ΔL=%2 (fill↔fill 1.07), dark'ta birebir --primary
67
+ // değeri (210 40% 98%) — secondary varyantı primary klonuna dönerdi.
68
+ // `--secondary-500` (tokens.css slate skalası, RGB üçlüsü) iki temada da
69
+ // track'e ≥3:1 (WCAG 1.4.11) veren TEK kademe: light 4.63, dark 4.23
70
+ // (gerçek kart #111827 üstünde 3.83; 400 light'ta 2.50 ✗, 600 dark'ta
71
+ // 2.66 ✗). Skala :root'ta tanımlı olduğundan tema değişiminde sabittir;
72
+ // tüketici --secondary-500'ü ezerek özelleştirebilir.
73
+ // Regresyon: __tests__/slider-secondary-contrast.test.ts
74
+ secondary: "bg-[rgb(var(--secondary-500))]",
62
75
  accent: "bg-accent",
63
76
  success: "bg-success",
64
77
  warning: "bg-warning",
@@ -78,7 +91,18 @@ const sliderThumbVariants = cva(
78
91
  variant: {
79
92
  default: "border-foreground bg-background",
80
93
  primary: "border-primary bg-background",
81
- secondary: "border-secondary bg-background",
94
+ // #407 (P2, #389'un range eksenine EK ayrı eksen): tutamacın kenarlığı
95
+ // `border-secondary`, dolgusu `bg-background` idi. Bu bileşen↔sayfa-zemini
96
+ // ekseni: `--secondary` light'ta neredeyse beyaz (rgb(235,239,245)), dark'ta
97
+ // `--background` ile neredeyse aynı → kenarlık 1.15 (light) / 1.01 (dark)
98
+ // oranında kalıyor, secondary slider'ın tutamacı zeminde kayboluyordu.
99
+ // #389'da bilerek kapsam dışıydı (mevcut kusuru testle "doğru" ilan etmemek için).
100
+ // Non-text UI bileşeni → WCAG 1.4.11 ≥3:1. Skala taraması bu eksende de TEK
101
+ // kademe bırakıyor — `--secondary-500`: light 4.76, dark 4.23 (gerçek kart
102
+ // #111827 üstünde 3.73); 400 light'ta 2.56 ✗, 600 dark'ta 2.66 ✗. Range ile
103
+ // aynı kademe: tutamaç ve dolgu tek renkte okunur.
104
+ // Regresyon: __tests__/slider-secondary-contrast.test.ts
105
+ secondary: "border-[rgb(var(--secondary-500))] bg-background",
82
106
  accent: "border-accent bg-background",
83
107
  success: "border-success bg-background",
84
108
  warning: "border-warning bg-background",