@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.
- package/dist/cdn/moonui.css +2 -2
- package/dist/cdn/moonui.esm.js +12 -12
- package/dist/cdn/moonui.global.js +12 -12
- package/dist/index.d.mts +24 -6
- package/dist/index.d.ts +24 -6
- package/dist/index.js +436 -93
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +437 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/issue-544-kontrast.test.tsx +153 -145
- package/src/__tests__/issue-645-overlay-tab-focus.test.tsx +262 -0
- package/src/__tests__/issue-646-dark-scope-override.test.tsx +297 -0
- package/src/__tests__/issue-661-a11y.test.tsx +113 -0
- package/src/__tests__/issue-661-token-kontrast.test.tsx +82 -0
- package/src/components/ui/__tests__/accent-foreground-contrast.test.ts +197 -0
- package/src/components/ui/__tests__/accordion.test.tsx +6 -4
- package/src/components/ui/__tests__/alert.test.tsx +4 -1
- package/src/components/ui/__tests__/button.test.tsx +6 -1
- package/src/components/ui/__tests__/dark-token-contrast.test.ts +18 -18
- package/src/components/ui/__tests__/heading-level-policy.test.tsx +4 -4
- package/src/components/ui/__tests__/issue-643-card-token-parite.test.tsx +113 -0
- package/src/components/ui/__tests__/issue-649-button-aschild.test.tsx +199 -0
- package/src/components/ui/__tests__/issue-664-separator-dekoratif-rol.test.tsx +41 -0
- package/src/components/ui/__tests__/preset-color-shadowing.test.ts +10 -10
- package/src/components/ui/__tests__/primary-subtle-token.test.ts +7 -7
- package/src/components/ui/__tests__/progress-secondary-contrast.test.ts +38 -38
- package/src/components/ui/__tests__/progress-warning-error-contrast.test.ts +64 -64
- package/src/components/ui/__tests__/quality-group-de.test.tsx +2 -2
- package/src/components/ui/__tests__/responsive-color-invariance.test.ts +20 -20
- package/src/components/ui/__tests__/select.test.tsx +6 -1
- package/src/components/ui/__tests__/semantic-token-usage.test.ts +23 -23
- package/src/components/ui/__tests__/size-scale-convention.test.ts +10 -10
- package/src/components/ui/__tests__/slider-accent-contrast.test.ts +142 -136
- package/src/components/ui/__tests__/slider-secondary-contrast.test.ts +60 -60
- package/src/components/ui/__tests__/slider-warning-error-contrast.test.ts +100 -100
- package/src/components/ui/__tests__/switch-variant-contrast.test.tsx +103 -103
- package/src/components/ui/__tests__/tooltip.test.tsx +122 -0
- package/src/components/ui/accordion.tsx +16 -3
- package/src/components/ui/alert.tsx +1 -1
- package/src/components/ui/badge.tsx +12 -1
- package/src/components/ui/button.tsx +95 -24
- package/src/components/ui/card.tsx +59 -5
- package/src/components/ui/dropdown-menu.tsx +99 -5
- package/src/components/ui/file-upload.tsx +4 -0
- package/src/components/ui/index.ts +4 -1
- package/src/components/ui/popover-pro.tsx +41 -0
- package/src/components/ui/popover.tsx +162 -7
- package/src/components/ui/rating.tsx +24 -2
- package/src/components/ui/select.tsx +4 -4
- package/src/components/ui/simple-editor.tsx +3 -18
- package/src/components/ui/tabs.tsx +16 -1
- package/src/components/ui/textarea.tsx +6 -1
- package/src/components/ui/toast.tsx +17 -2
- package/src/components/ui/tooltip.tsx +154 -2
- package/src/styles/tokens.css +34 -2
|
@@ -65,7 +65,119 @@ const popoverContentVariants = cva(
|
|
|
65
65
|
}
|
|
66
66
|
);
|
|
67
67
|
|
|
68
|
-
|
|
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-
|
|
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
|
|
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={
|
|
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
|
|
300
|
-
variant === "warning" && "text-warning dark:text-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// Server-side: simple regex-based HTML stripping
|
|
290
|
-
return html
|
|
291
|
-
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
|
292
|
-
.replace(/ /g, ' ') // Replace with space
|
|
293
|
-
.replace(/&/g, '&') // Replace & with &
|
|
294
|
-
.replace(/</g, '<') // Replace < with <
|
|
295
|
-
.replace(/>/g, '>') // Replace > with >
|
|
296
|
-
.replace(/"/g, '"') // Replace " with "
|
|
297
|
-
.replace(/'/g, "'") // Replace ' 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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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>,
|
package/src/styles/tokens.css
CHANGED
|
@@ -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
|
|
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);
|