@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
@@ -65,7 +65,119 @@ const popoverContentVariants = cva(
65
65
  }
66
66
  );
67
67
 
68
- const Popover = PopoverPrimitive.Root;
68
+ /* #645 Tab odağı popover konteynerinde donuyordu.
69
+ *
70
+ * KÖK NEDEN (Radix kaynağı, `@radix-ui/react-popover` → `PopoverContentImpl`):
71
+ * içerik her zaman `FocusScope`'a `loop: true` ile sarılır; `trapped` yalnız
72
+ * `modal` popover'da true olur. Ancak `FocusScope`'un Tab işleyicisi
73
+ * `if (!loop && !trapped) return;` diyerek `loop` tek başına yeterli sayılıyor:
74
+ * - içerikte hiç odaklanabilir eleman YOKSA → `event.preventDefault()` (odak donar)
75
+ * - varsa → son elemandan sonra başa döndürür (non-modal'da yanlış tuzak)
76
+ * Yani kusur sarmalayıcımızda değil, Radix'in devraldığımız davranışında.
77
+ *
78
+ * SEÇİLEN SEMANTİK: popover = non-modal disclosure. Tab içeriğe girer, içeride
79
+ * doğal DOM sırasında ilerler, KENARDA (ilk/son eleman ya da hiç odaklanabilir
80
+ * yok) overlay'den çıkar; çıkış Radix'in `onFocusOutside` yolunu tetiklediği için
81
+ * popover kapanır. `modal` popover'da WAI-ARIA gereği tuzak KORUNUR.
82
+ */
83
+ const PopoverModalContext = React.createContext(false);
84
+
85
+ /** Bir düğümün gerçek bir Tab durağı olup olmadığı. */
86
+ const POPOVER_TAB_STOP_SELECTOR =
87
+ "a[href],button,input,select,textarea,[tabindex],audio[controls],video[controls],[contenteditable]";
88
+
89
+ /* Görünürlük ölçütü bilinçli olarak Radix'in `findVisible`/`isHidden` mantığıyla
90
+ AYNI: `getClientRects()` yerine hesaplanmış stil. Böylece hem tarayıcıda hem
91
+ düzen motoru olmayan jsdom'da aynı sonucu verir. */
92
+ function isPopoverElementHidden(node: HTMLElement) {
93
+ const view = node.ownerDocument.defaultView;
94
+ if (!view) return false;
95
+ if (view.getComputedStyle(node).visibility === "hidden") return true;
96
+ let current: HTMLElement | null = node;
97
+ while (current) {
98
+ if (view.getComputedStyle(current).display === "none") return true;
99
+ current = current.parentElement;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ function isPopoverTabStop(node: HTMLElement) {
105
+ if (node.hasAttribute("data-radix-focus-guard")) return false;
106
+ if (node.tabIndex < 0) return false;
107
+ if ((node as HTMLButtonElement).disabled) return false;
108
+ if (node.hidden) return false;
109
+ if (node.getAttribute("aria-hidden") === "true") return false;
110
+ if (node.closest("[inert]")) return false;
111
+ return !isPopoverElementHidden(node);
112
+ }
113
+
114
+ function getPopoverTabStopsWithin(container: HTMLElement) {
115
+ return Array.from(
116
+ container.querySelectorAll<HTMLElement>(POPOVER_TAB_STOP_SELECTOR)
117
+ ).filter(isPopoverTabStop);
118
+ }
119
+
120
+ /** Radix tetikleyiciyi `aria-controls` ile içeriğe bağlar; çıkış referansı odur. */
121
+ function getPopoverTrigger(content: HTMLElement) {
122
+ if (!content.id) return null;
123
+ const id =
124
+ typeof CSS !== "undefined" && typeof CSS.escape === "function"
125
+ ? CSS.escape(content.id)
126
+ : content.id;
127
+ return content.ownerDocument.querySelector<HTMLElement>(
128
+ `[aria-controls="${id}"]`
129
+ );
130
+ }
131
+
132
+ /** İçerik dışında, referansa göre bir sonraki/önceki gerçek Tab durağı. */
133
+ function getPopoverTabStopOutside(
134
+ reference: HTMLElement,
135
+ content: HTMLElement,
136
+ direction: "next" | "previous"
137
+ ) {
138
+ const candidates = Array.from(
139
+ content.ownerDocument.querySelectorAll<HTMLElement>(
140
+ POPOVER_TAB_STOP_SELECTOR
141
+ )
142
+ ).filter((node) => !content.contains(node) && isPopoverTabStop(node));
143
+
144
+ if (direction === "next") {
145
+ return (
146
+ candidates.find((node) =>
147
+ Boolean(
148
+ reference.compareDocumentPosition(node) &
149
+ Node.DOCUMENT_POSITION_FOLLOWING
150
+ )
151
+ ) ?? null
152
+ );
153
+ }
154
+
155
+ let previous: HTMLElement | null = null;
156
+ for (const node of candidates) {
157
+ const relation = reference.compareDocumentPosition(node);
158
+ if (node === reference || relation & Node.DOCUMENT_POSITION_PRECEDING) {
159
+ previous = node;
160
+ } else {
161
+ break;
162
+ }
163
+ }
164
+ return previous;
165
+ }
166
+
167
+ /* Yeni public export açılmaz (#667) — tip yerel kalır. */
168
+ type PopoverRootProps = React.ComponentPropsWithoutRef<
169
+ typeof PopoverPrimitive.Root
170
+ >;
171
+
172
+ const Popover = ({ modal = false, children, ...props }: PopoverRootProps) => (
173
+ <PopoverModalContext.Provider value={modal}>
174
+ <PopoverPrimitive.Root {...props} modal={modal}>
175
+ {children}
176
+ </PopoverPrimitive.Root>
177
+ </PopoverModalContext.Provider>
178
+ );
179
+ Popover.displayName = "Popover";
180
+
69
181
  const PopoverTrigger = PopoverPrimitive.Trigger;
70
182
  const PopoverAnchor = PopoverPrimitive.Anchor;
71
183
 
@@ -101,8 +213,49 @@ const PopoverContent = React.forwardRef<
101
213
  closeOnInteractOutside = true,
102
214
  overlayBackdrop = false,
103
215
  sideOffset = 4,
216
+ onKeyDown,
104
217
  ...props
105
- }, ref) => (
218
+ }, ref) => {
219
+ const isModal = React.useContext(PopoverModalContext);
220
+
221
+ /* #645: yalnız KENAR durumlarını devral — Radix'in içerideki doğal Tab
222
+ akışına dokunulmaz. Modal popover'da hiç devreye girmez. */
223
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
224
+ onKeyDown?.(event);
225
+ if (event.defaultPrevented) return;
226
+ if (isModal) return;
227
+ if (event.key !== "Tab") return;
228
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
229
+
230
+ const content = event.currentTarget;
231
+ const active = content.ownerDocument.activeElement as HTMLElement | null;
232
+ if (!active || (active !== content && !content.contains(active))) return;
233
+
234
+ const tabStops = getPopoverTabStopsWithin(content);
235
+ const forward = !event.shiftKey;
236
+ const atEdge =
237
+ tabStops.length === 0
238
+ ? active === content
239
+ : active === (forward ? tabStops[tabStops.length - 1] : tabStops[0]);
240
+ if (!atEdge) return;
241
+
242
+ const reference = getPopoverTrigger(content) ?? content;
243
+ const target = getPopoverTabStopOutside(
244
+ reference,
245
+ content,
246
+ forward ? "next" : "previous"
247
+ );
248
+
249
+ event.preventDefault();
250
+ if (target) {
251
+ target.focus();
252
+ } else {
253
+ // Gidilecek durak yoksa bile odak konteynerde kilitli KALMAMALI.
254
+ active.blur();
255
+ }
256
+ };
257
+
258
+ return (
106
259
  <>
107
260
  {overlayBackdrop && (
108
261
  <div
@@ -126,11 +279,12 @@ const PopoverContent = React.forwardRef<
126
279
  e.preventDefault();
127
280
  }
128
281
  }}
282
+ onKeyDown={handleKeyDown}
129
283
  className={cn(
130
- popoverContentVariants({
131
- variant,
132
- size,
133
- side,
284
+ popoverContentVariants({
285
+ variant,
286
+ size,
287
+ side,
134
288
  position,
135
289
  radius,
136
290
  shadow
@@ -141,7 +295,8 @@ const PopoverContent = React.forwardRef<
141
295
  {...props}
142
296
  />
143
297
  </>
144
- ));
298
+ );
299
+ });
145
300
  PopoverContent.displayName = PopoverPrimitive.Content.displayName;
146
301
 
147
302
  /**
@@ -138,6 +138,20 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
138
138
  [readOnly, max, currentValue, step, commit]
139
139
  );
140
140
 
141
+ // #661-7: `role="radiogroup"` sarmalayıcısı tek tab durağıydı ve her
142
+ // `role="radio"` düğmesi `tabIndex={-1}` taşıyordu; ne roving-tabindex ne de
143
+ // `aria-activedescendant` vardı. Klavyeyle değer DEĞİŞİYORDU ama yardımcı
144
+ // teknolojiye HANGİ radyonun aktif olduğu hiç bildirilmiyordu (ARIA composite
145
+ // widget sözleşmesi ihlali). Odak DOM'da yerinde kalsın diye
146
+ // `aria-activedescendant` yolu seçildi — klavye davranışı değişmez.
147
+ const reactId = React.useId();
148
+ const optionId = React.useCallback(
149
+ (optionValue: number) => `${reactId}-option-${String(optionValue).replace(".", "-")}`,
150
+ [reactId]
151
+ );
152
+ // Henüz seçim yoksa ARIA'nın istediği gibi İLK seçenek aktif kabul edilir.
153
+ const activeValue = currentValue >= step && currentValue <= max ? currentValue : step;
154
+
141
155
  // İkonu doldurmalı/boş olarak üret (özel ikon desteklenir)
142
156
  const renderIcon = (filled: boolean): React.ReactNode => {
143
157
  if (React.isValidElement(icon)) {
@@ -168,6 +182,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
168
182
  aria-label={ariaLabel}
169
183
  aria-orientation="horizontal"
170
184
  aria-readonly={readOnly || undefined}
185
+ aria-activedescendant={readOnly ? undefined : optionId(activeValue)}
171
186
  tabIndex={readOnly ? undefined : 0}
172
187
  onKeyDown={handleKeyDown}
173
188
  onMouseLeave={() => setHoverValue(null)}
@@ -193,10 +208,14 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
193
208
  {renderIcon(false)}
194
209
  </span>
195
210
 
196
- {/* Dolu katman — overflow-hidden maske ile kısmi doldurma */}
211
+ {/* Dolu katman — overflow-hidden maske ile kısmi doldurma.
212
+ #664 S3: `text-amber-400` → `--rating-star-rgb` (tokens.css).
213
+ Değer AYNI (Tailwind amber-400) — yıldız rozeti bir DURUM rengi
214
+ değil marka/konvansiyon rengidir, `--warning` token'ına
215
+ çevirmek farklı bir tona (yellow ailesi) kayardı (§9). */}
197
216
  <span
198
217
  aria-hidden="true"
199
- className="pointer-events-none absolute left-0 top-0 h-full overflow-hidden text-amber-400"
218
+ className="pointer-events-none absolute left-0 top-0 h-full overflow-hidden text-[rgb(var(--rating-star-rgb))]"
200
219
  style={{ width: `${fraction * 100}%` }}
201
220
  >
202
221
  {renderIcon(true)}
@@ -208,6 +227,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
208
227
  <button
209
228
  type="button"
210
229
  role="radio"
230
+ id={optionId(halfValue)}
211
231
  aria-checked={currentValue === halfValue}
212
232
  aria-label={`${halfValue} stars`}
213
233
  tabIndex={-1}
@@ -218,6 +238,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
218
238
  <button
219
239
  type="button"
220
240
  role="radio"
241
+ id={optionId(starValue)}
221
242
  aria-checked={currentValue === starValue}
222
243
  aria-label={`${starValue} stars`}
223
244
  tabIndex={-1}
@@ -232,6 +253,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
232
253
  <button
233
254
  type="button"
234
255
  role="radio"
256
+ id={optionId(starValue)}
235
257
  aria-checked={currentValue === starValue}
236
258
  aria-label={`${starValue} ${starValue === 1 ? "star" : "stars"}`}
237
259
  tabIndex={-1}
@@ -114,7 +114,7 @@ const SelectTrigger = React.forwardRef<
114
114
  * Tüketici kendi `aria-label`/`aria-labelledby`sini verdiyse ona dokunulmaz.
115
115
  */
116
116
  const icerikId = React.useId()
117
- const adVerilmis =
117
+ const isNamed =
118
118
  props["aria-label"] != null || props["aria-labelledby"] != null
119
119
 
120
120
  return (
@@ -136,7 +136,7 @@ const SelectTrigger = React.forwardRef<
136
136
  data-error={error ? true : undefined}
137
137
  data-success={success ? true : undefined}
138
138
  data-loading={loading ? true : undefined}
139
- aria-labelledby={adVerilmis ? props["aria-labelledby"] : icerikId}
139
+ aria-labelledby={isNamed ? props["aria-labelledby"] : icerikId}
140
140
  >
141
141
  <div id={icerikId} className="flex items-center flex-1 gap-2">
142
142
  {leftIcon && (
@@ -296,8 +296,8 @@ const SelectItem = React.forwardRef<
296
296
  variant === "default" && "focus:bg-accent focus:text-accent-foreground dark:focus:bg-gray-700 dark:focus:text-gray-100",
297
297
  variant === "subtle" && "focus:bg-gray-100 dark:focus:bg-gray-800 focus:text-foreground dark:focus:text-gray-200",
298
298
  variant === "destructive" && "text-error dark:text-red-400 focus:bg-error/10 dark:focus:bg-red-900/20 focus:text-error dark:focus:text-red-300",
299
- variant === "success" && "text-success dark:text-green-400 focus:bg-success/10 dark:focus:bg-green-900/20 focus:text-success dark:focus:text-green-300",
300
- variant === "warning" && "text-warning dark:text-yellow-400 focus:bg-warning/10 dark:focus:bg-yellow-900/20 focus:text-warning dark:focus:text-yellow-300",
299
+ variant === "success" && "text-success focus:bg-success/10 focus:text-success",
300
+ variant === "warning" && "text-warning-700 dark:text-warning-500 focus:bg-warning/10 focus:text-warning-700 dark:focus:text-warning-500",
301
301
 
302
302
  className
303
303
  )}
@@ -283,24 +283,9 @@ export const SimpleEditor = React.forwardRef<HTMLDivElement, SimpleEditorProps>(
283
283
  }
284
284
  }, [executeCommand, updateContent])
285
285
 
286
- // Convert HTML to plain text for preview
287
- const getPlainText = (html: string) => {
288
- if (typeof window === 'undefined') {
289
- // Server-side: simple regex-based HTML stripping
290
- return html
291
- .replace(/<[^>]*>/g, '') // Remove HTML tags
292
- .replace(/&nbsp;/g, ' ') // Replace &nbsp; with space
293
- .replace(/&amp;/g, '&') // Replace &amp; with &
294
- .replace(/&lt;/g, '<') // Replace &lt; with <
295
- .replace(/&gt;/g, '>') // Replace &gt; with >
296
- .replace(/&quot;/g, '"') // Replace &quot; with "
297
- .replace(/&#39;/g, "'") // Replace &#39; with '
298
- }
299
- // Client-side: use DOM for accurate conversion
300
- const div = document.createElement('div')
301
- div.innerHTML = html
302
- return div.textContent || div.innerText || ''
303
- }
286
+ /* #664 (S9): burada duran ikinci bir `getPlainText` kopyası SİLİNDİ — component
287
+ gövdesinde tanımlanıp hiçbir yerden çağrılmıyordu (0 çağrı yeri). Karakter
288
+ sayacı kendi kopyasını `CharacterCount` içinde tutuyor; canlı olan odur. */
304
289
 
305
290
  React.useEffect(() => {
306
291
  if (editorRef.current && content && content !== editorRef.current.innerHTML) {
@@ -132,7 +132,22 @@ const tabsTriggerVariants = cva(
132
132
  variant: {
133
133
  default: "rounded-md data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm dark:data-[state=active]:bg-gray-900 dark:data-[state=active]:text-gray-100 dark:data-[state=active]:shadow-gray-950/10",
134
134
  underline: "rounded-none border-b-2 border-transparent pb-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground dark:data-[state=active]:border-primary/80 dark:data-[state=active]:text-gray-100",
135
- pills: "rounded-lg bg-gray-100 hover:bg-gray-200/80 border border-gray-300 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:border-primary dark:bg-gray-800 dark:hover:bg-gray-700 dark:border-gray-700 dark:data-[state=active]:bg-primary/90 dark:data-[state=active]:text-white dark:data-[state=active]:border-primary",
135
+ // #646: `dark:data-[state=active]:text-white` KALDIRILDI. `--primary` koyu
136
+ // temada BİLİNÇLİ near-white'tır (tokens.css:500, #368 kararı — DEĞİŞMEZ),
137
+ // dolayısıyla aktif pill'e açıkça beyaz metin yazmak beyaz-üstüne-beyaz
138
+ // demekti: ölçülen 1.28:1 (gerçek tarayıcı, /docs/components/tabs, koyu tema).
139
+ // Sınıf kalkınca öneksiz `data-[state=active]:text-primary-foreground`
140
+ // (koyu temada near-black) devreye girer → 15.91:1. Açık temada hiçbir
141
+ // değişiklik yok (`dark:` sınıfı orada zaten uygulanmıyordu; ölçülen 4.70:1).
142
+ //
143
+ // ⚠️ `dark:data-[state=active]:bg-primary/90` BİLEREK KORUNDU: onu da
144
+ // kaldırmak #545'in "ihlal takası" tuzağına düşürüyor. Aynı varyanttaki
145
+ // `dark:bg-gray-800` (inaktif zemin) ile `data-[state=active]:bg-primary`
146
+ // ÖZGÜLLÜKÇE EŞİT (0,2,0); kaynak sırası nedeniyle `dark:bg-gray-800`
147
+ // kazanıyor ve aktif pill koyu temada inaktif görünüyordu — near-black metin
148
+ // gray-800 üstünde 1.41:1 ile YENİ bir ihlal (ölçüldü). Koyu temada aktif
149
+ // durumun zemini bu yüzden `dark:` özgüllüğünde kalmak ZORUNDA.
150
+ pills: "rounded-lg bg-gray-100 hover:bg-gray-200/80 border border-gray-300 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:border-primary dark:bg-gray-800 dark:hover:bg-gray-700 dark:border-gray-700 dark:data-[state=active]:bg-primary/90 dark:data-[state=active]:border-primary",
136
151
  cards: "rounded-md bg-muted/50 hover:bg-muted data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-md dark:bg-gray-800/50 dark:hover:bg-gray-700/50 dark:data-[state=active]:bg-gray-900 dark:data-[state=active]:text-gray-100 dark:data-[state=active]:shadow-gray-950/20",
137
152
  minimal: "rounded-sm bg-transparent hover:bg-muted/30 data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:underline data-[state=active]:underline-offset-4 dark:hover:bg-gray-800/30 dark:data-[state=active]:text-gray-200",
138
153
  },
@@ -222,8 +222,13 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
222
222
  )}
223
223
 
224
224
  {/* Character Count */}
225
+ {/* #661-14: `dark:text-gray-500` KALDIRILDI. `text-muted-foreground`
226
+ zaten koyu temada 62% L üretiyor (kart üstünde 6.29:1); üstüne
227
+ binen `dark:` katmanı gray-500'e düşürüp 3.68:1 veriyordu — yani
228
+ token DOĞRUYKEN ikinci katman onu AA altına indiriyordu (#646 ile
229
+ aynı kusur sınıfı). Açık temada değişiklik yok. */}
225
230
  {showCharCount && (
226
- <p className="text-xs text-muted-foreground dark:text-gray-500 ml-auto">
231
+ <p className="text-xs text-muted-foreground ml-auto">
227
232
  {currentLength}{maxLength !== undefined && ` / ${maxLength}`}
228
233
  </p>
229
234
  )}
@@ -160,7 +160,22 @@ const addToRemoveQueue = (toastId: string) => {
160
160
  toastTimeouts.set(toastId, timeout);
161
161
  };
162
162
 
163
- const reducer = (state: State, action: any): State => {
163
+ /**
164
+ * Store eylemleri. Önceden `action: any` idi (#664 S1): `dispatch({ type: "TYPO" })`
165
+ * gibi bir çağrı sessizce `default` dalına düşüp hiçbir şey yapmıyordu ve tip
166
+ * denetimi bunu yakalayamıyordu. Ayrımlı birlik (discriminated union) o dalı
167
+ * derleme zamanına taşır.
168
+ *
169
+ * Pro ikiziyle (`packages/moonui-pro/src/components/ui/toast.tsx`, #651) birebir
170
+ * aynı birlik — ikiz parite kapısının koruduğu eşleşme.
171
+ */
172
+ type ToastStoreAction =
173
+ | { type: "ADD_TOAST"; toast: ToastData }
174
+ | { type: "UPDATE_TOAST"; toast: Partial<ToastData> & { id: string } }
175
+ | { type: "DISMISS_TOAST"; toastId?: string }
176
+ | { type: "REMOVE_TOAST"; toastId?: string };
177
+
178
+ const reducer = (state: State, action: ToastStoreAction): State => {
164
179
  switch (action.type) {
165
180
  case "ADD_TOAST":
166
181
  return {
@@ -220,7 +235,7 @@ const listeners: Array<(state: State) => void> = [];
220
235
  let memoryState: State = { toasts: [] };
221
236
  let toastCounter = 0;
222
237
 
223
- function dispatch(action: any) {
238
+ function dispatch(action: ToastStoreAction) {
224
239
  memoryState = reducer(memoryState, action);
225
240
  listeners.forEach((listener) => {
226
241
  listener(memoryState);
@@ -33,9 +33,161 @@ const tooltipVariants = cva(
33
33
  }
34
34
  )
35
35
 
36
- const Tooltip = TooltipPrimitive.Root
36
+ /* -------------------------------------------------------------------------------
37
+ * #650 — klavye odağı kilidi (WCAG 1.4.13 "Content on Hover or Focus")
38
+ *
39
+ * Radix `TooltipContentImpl` içinde KOŞULSUZ bir "kaydırma → kapat" etkisi var:
40
+ *
41
+ * window.addEventListener("scroll", (e) => {
42
+ * if (e.target?.contains(trigger)) onClose()
43
+ * }, { capture: true })
44
+ *
45
+ * Bu davranış FARE tooltip'i için doğrudur (sayfa imlecin altından kayarsa tooltip
46
+ * bayatlar) ama KLAVYE odağı için yıkıcıdır: görünür alanın dışındaki bir
47
+ * tetikleyiciye `Tab` ile gelindiğinde tarayıcı elemanı görünür alana KAYDIRIR ve
48
+ * bu kaydırma tooltip'i açıldığı anda kapatır.
49
+ *
50
+ * Gerçek tarayıcıda ölçülen sıra (docs sayfası, 365 gerçek `Tab` basışı):
51
+ *
52
+ * 8244.1 FOCUSIN trigger (scroll-behavior: smooth)
53
+ * 8248.6 data-state=instant-open ← Radix onFocus açtı (+4.5 ms)
54
+ * 8251.7 SCROLL #1 target=document containsTrigger=true
55
+ * 8255.0 data-state=closed ← Radix handleScroll kapattı (+6.4 ms)
56
+ * ... toplam 34 kaydırma olayı (yumuşak kaydırma)
57
+ *
58
+ * Sonuç: klavye ve ekran-okuyucu kullanıcısı içeriği HİÇ göremiyor. Tetikleyici
59
+ * görünür alandaysa (kısa sayfa, izole test) hata görünmez — bu yüzden birim
60
+ * testlerinden kaçmıştı.
61
+ *
62
+ * Çözüm: tetikleyici KLAVYE odağını tuttuğu sürece gelen kapatma isteklerini yok
63
+ * say. Kilit yalnızca `:focus-visible` eşleşen odakta kurulur, yani fare
64
+ * etkileşiminin davranışı hiç değişmez. `Escape` ve işaretleyici (pointer)
65
+ * etkileşimi kilidi açar — 1.4.13'ün "Dismissible" şartı korunur.
66
+ * ---------------------------------------------------------------------------- */
37
67
 
38
- const TooltipTrigger = TooltipPrimitive.Trigger
68
+ type TooltipFocusHold = {
69
+ hold: () => void
70
+ release: () => void
71
+ }
72
+
73
+ const TooltipFocusHoldContext = React.createContext<TooltipFocusHold | null>(null)
74
+
75
+ /**
76
+ * Odağın klavyeden gelip gelmediğini söyler. jsdom `:focus-visible`i desteklemez
77
+ * ve `matches()` atar; bu ortamda kilit kurulur (test edilebilirlik) çünkü jsdom'da
78
+ * gerçek bir işaretleyici yoktur.
79
+ */
80
+ function isKeyboardFocus(element: Element | null | undefined): boolean {
81
+ if (!element) return false
82
+ try {
83
+ return element.matches(":focus-visible")
84
+ } catch {
85
+ return true
86
+ }
87
+ }
88
+
89
+ const Tooltip = ({
90
+ open,
91
+ defaultOpen,
92
+ onOpenChange,
93
+ children,
94
+ ...props
95
+ }: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root>) => {
96
+ const isControlled = open !== undefined
97
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false)
98
+ const resolvedOpen = isControlled ? open : uncontrolledOpen
99
+
100
+ // Tetikleyici klavye odağını tutuyor mu? Kapatma isteklerini süzmek için kullanılır.
101
+ const isFocusHeldRef = React.useRef(false)
102
+
103
+ const hold = React.useCallback(() => {
104
+ isFocusHeldRef.current = true
105
+ }, [])
106
+ const release = React.useCallback(() => {
107
+ isFocusHeldRef.current = false
108
+ }, [])
109
+
110
+ const setOpen = React.useCallback(
111
+ (next: boolean) => {
112
+ if (!isControlled) setUncontrolledOpen(next)
113
+ onOpenChange?.(next)
114
+ },
115
+ [isControlled, onOpenChange]
116
+ )
117
+
118
+ const handleOpenChange = React.useCallback(
119
+ (next: boolean) => {
120
+ // Klavye odağı tetikleyicideyken kapatma isteğini yut (kaydırma, işaretleyici
121
+ // ayrılması vb.). Açma istekleri her zaman geçer.
122
+ if (!next && isFocusHeldRef.current) return
123
+ setOpen(next)
124
+ },
125
+ [setOpen]
126
+ )
127
+
128
+ // 1.4.13 "Dismissible": `Escape` kilidi açar ve kapatır. Yakalama (capture)
129
+ // fazında dinlenir; böylece Radix'in kendi `DismissableLayer` dinleyicisiyle
130
+ // kayıt sırasından BAĞIMSIZ olarak önce çalışır ve kapatma asla yutulmaz.
131
+ React.useEffect(() => {
132
+ if (!resolvedOpen) return
133
+ const handleEscape = (event: KeyboardEvent) => {
134
+ if (event.key !== "Escape") return
135
+ isFocusHeldRef.current = false
136
+ setOpen(false)
137
+ }
138
+ document.addEventListener("keydown", handleEscape, true)
139
+ return () => document.removeEventListener("keydown", handleEscape, true)
140
+ }, [resolvedOpen, setOpen])
141
+
142
+ const focusHold = React.useMemo<TooltipFocusHold>(
143
+ () => ({ hold, release }),
144
+ [hold, release]
145
+ )
146
+
147
+ return (
148
+ <TooltipFocusHoldContext.Provider value={focusHold}>
149
+ <TooltipPrimitive.Root
150
+ {...props}
151
+ open={resolvedOpen}
152
+ onOpenChange={handleOpenChange}
153
+ >
154
+ {children}
155
+ </TooltipPrimitive.Root>
156
+ </TooltipFocusHoldContext.Provider>
157
+ )
158
+ }
159
+ Tooltip.displayName = "Tooltip"
160
+
161
+ const TooltipTrigger = React.forwardRef<
162
+ React.ElementRef<typeof TooltipPrimitive.Trigger>,
163
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
164
+ >(({ onFocus, onBlur, onPointerDown, ...props }, ref) => {
165
+ const focusHold = React.useContext(TooltipFocusHoldContext)
166
+
167
+ return (
168
+ <TooltipPrimitive.Trigger
169
+ ref={ref}
170
+ {...props}
171
+ onFocus={(event) => {
172
+ // Yalnızca klavye odağı kilitlenir; fare tıklaması sonrası odak
173
+ // (`:focus-visible` eşleşmez) Radix'in normal akışında bırakılır.
174
+ if (isKeyboardFocus(event.currentTarget)) focusHold?.hold()
175
+ onFocus?.(event)
176
+ }}
177
+ onBlur={(event) => {
178
+ // Odak ayrıldı → kilit kalkar, Radix'in kendi kapatması işler.
179
+ focusHold?.release()
180
+ onBlur?.(event)
181
+ }}
182
+ onPointerDown={(event) => {
183
+ // Fare etkileşimi kilidi açar; "tıkla-kapat" davranışı korunur.
184
+ focusHold?.release()
185
+ onPointerDown?.(event)
186
+ }}
187
+ />
188
+ )
189
+ })
190
+ TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName
39
191
 
40
192
  const TooltipArrow = React.forwardRef<
41
193
  React.ElementRef<typeof TooltipPrimitive.Arrow>,
@@ -145,7 +145,15 @@
145
145
  --background üzerinde zaten 4.76 geçiyordu; kırılan yüzey bg-muted (kbd). */
146
146
  --muted-foreground: 215.4 16.3% 45%;
147
147
  --accent: 217.2 32.6% 94%;
148
- --accent-foreground: 217.2 91.2% 51%;
148
+ /* #647: --accent-foreground 51% idi -> 4.26 (AA 4.5 altı). Zemin (--accent, 94%)
149
+ --secondary ile BİREBİR aynı token değeri; #284'ün --secondary-foreground'da
150
+ uyguladığı düzeltmenin (51%→49%) aynısı burada da geçerli: aynı zemin + aynı
151
+ başlangıç L → aynı sonuç, 4.54. --accent zemin rolünde DEĞİŞMEDİ (dört yüzey de
152
+ --accent'i zemin, --accent-foreground'u metin olarak kullanıyor: toggle-group,
153
+ command/command-pro seçili satır, navbar aktif öğe, magnetic-button outline/ghost
154
+ hover — inline style `hsl(var(--accent-foreground))` okuduğu için component
155
+ kaynağına dokunmadan düzelir). Koyu tema zaten 14.11 → dokunulmadı. */
156
+ --accent-foreground: 217.2 91.2% 49%;
149
157
  /* #327: 60.2% idi. HİÇBİR kullanımda AA'yı geçmiyordu — beyaz zeminde metin 3.76,
150
158
  kendi foreground'uyla düz dolgu 3.59. 48%, her İKİ testi de geçen en açık değer
151
159
  (4.86 / 4.64), yani marka rengine en yakın kalan seçenek. Koyu tema (0 63% 31%)
@@ -356,7 +364,31 @@
356
364
  --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
357
365
  --shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
358
366
  --shadow-none: 0 0 #0000;
359
-
367
+
368
+ /* === #664 S3: DEKORATİF HAM-LİTERAL TOKEN'LARI ===
369
+ *
370
+ * Bu grup, hiçbir mevcut semantik token'a (--primary/--success/--warning/…)
371
+ * KARŞILIK GELMEYEN dekoratif renkleri barındırır — durum anlamı taşımazlar,
372
+ * yalnız sabit bir görsel kimliğin (glow/neon efekti, yıldız rozeti rengi,
373
+ * sürükleme önizleme gölgesi) parçasıdır. §9 gereği var olan rengi KAYDIRMIYORUZ:
374
+ * her değer, değiştirdiği ham Tailwind/rgba literaliyle PİKSEL-ÖZDEŞ tutulur —
375
+ * tek fark artık tek merkezden (burada) tanımlı olmaları ve `rgba(`/hex taramasının
376
+ * component kaynağında bir daha bunları YAKALAMAMASI.
377
+ *
378
+ * RGB üçlüsü formatı (`R G B`, boşlukla ayrık) — `--secondary-500` ile aynı
379
+ * kalıp, `rgb(var(--x))` / `rgba(var(--x),alpha)` Tailwind keyfi-değer
380
+ * söz dizimiyle tüketilir (kanıtlı kalıp: slider.tsx/switch.tsx/progress.tsx).
381
+ */
382
+ --glow-blue-rgb: 59 130 246; /* Tailwind blue-500 — button "glow", dialog/menubar/context-menu "neon" gölgesi */
383
+ --glow-green-rgb: 74 222 128; /* Tailwind green-400 — button "neon" gölgesi */
384
+ --glow-purple-rgb: 139 92 246; /* Tailwind violet-500 — tabs-pro "neon" gölgesi */
385
+ --rating-star-rgb: 251 191 36; /* Tailwind amber-400 — rating yıldız dolgu rengi (durum değil, marka rengi) */
386
+
387
+ /* Sürükleme önizleme gölgesi: draggable-list `dragPreviewStyle="clone"` — siyah
388
+ * gölge kasıtlı olarak tema-bağımsız (koyu temada da beyaz değil, siyah gölge
389
+ * kullanılır — çoğu tasarım sisteminin box-shadow konvansiyonu budur). */
390
+ --drag-preview-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
391
+
360
392
  /* Elevation System */
361
393
  --elevation-0: var(--shadow-none);
362
394
  --elevation-1: var(--shadow-sm);