@moontra/moonui 6.24.0 → 6.25.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 +417 -87
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +418 -89
- 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 +76 -18
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { Slot, Slottable } from "@radix-ui/react-slot";
|
|
4
5
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
6
|
import { cn } from "../../lib/utils";
|
|
6
7
|
import { Loader2 } from "lucide-react";
|
|
@@ -112,9 +113,11 @@ const buttonVariants = cva(
|
|
|
112
113
|
],
|
|
113
114
|
glow: [
|
|
114
115
|
"bg-blue-500 text-white",
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"
|
|
116
|
+
// #664 S3: rgba(59,130,246,…) → --glow-blue-rgb (tokens.css). Değer AYNI
|
|
117
|
+
// (Tailwind blue-500), yalnız merkezi token'a taşındı — görsel değişiklik yok.
|
|
118
|
+
"shadow-[0_0_30px_rgba(var(--glow-blue-rgb),0.5)]",
|
|
119
|
+
"hover:shadow-[0_0_40px_rgba(var(--glow-blue-rgb),0.7)]",
|
|
120
|
+
"active:shadow-[0_0_20px_rgba(var(--glow-blue-rgb),0.4)]",
|
|
118
121
|
"hover:bg-blue-600",
|
|
119
122
|
"focus-visible:ring-blue-500/50",
|
|
120
123
|
"transition-all duration-300",
|
|
@@ -143,8 +146,9 @@ const buttonVariants = cva(
|
|
|
143
146
|
"bg-transparent text-green-400",
|
|
144
147
|
"border-2 border-green-400",
|
|
145
148
|
"hover:bg-green-400 hover:text-gray-900",
|
|
146
|
-
|
|
147
|
-
"
|
|
149
|
+
// #664 S3: rgba(74,222,128,…) → --glow-green-rgb (tokens.css), piksel-özdeş.
|
|
150
|
+
"hover:shadow-[0_0_30px_rgba(var(--glow-green-rgb),0.6)]",
|
|
151
|
+
"active:shadow-[0_0_15px_rgba(var(--glow-green-rgb),0.4)]",
|
|
148
152
|
"focus-visible:ring-green-400/50",
|
|
149
153
|
"transition-all duration-300",
|
|
150
154
|
],
|
|
@@ -200,14 +204,21 @@ export interface ButtonProps
|
|
|
200
204
|
* @param props.rightIcon - Icon on the right side
|
|
201
205
|
* @param props.fullWidth - Full width display
|
|
202
206
|
* @param props.rounded - Corner rounding style (default, full, none)
|
|
203
|
-
* @param props.asChild -
|
|
207
|
+
* @param props.asChild - Renders the single child element instead of a native
|
|
208
|
+
* `<button>` (Radix `Slot`), e.g. `<Button asChild><a href="/x">Go</a></Button>`.
|
|
209
|
+
* The `buttonVariants` classes, the loading spinner and `leftIcon`/`rightIcon`
|
|
210
|
+
* are merged INTO that child. Because non-button elements ignore the native
|
|
211
|
+
* `disabled` attribute, `disabled`/`loading` are expressed as `aria-disabled` +
|
|
212
|
+
* `data-disabled` plus inert styling, and activation is blocked in the click
|
|
213
|
+
* handler. Falls back to a native `<button>` when the child is not a valid
|
|
214
|
+
* React element.
|
|
204
215
|
*/
|
|
205
216
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
206
|
-
({
|
|
207
|
-
className,
|
|
208
|
-
variant,
|
|
209
|
-
size,
|
|
210
|
-
rounded,
|
|
217
|
+
({
|
|
218
|
+
className,
|
|
219
|
+
variant,
|
|
220
|
+
size,
|
|
221
|
+
rounded,
|
|
211
222
|
fullWidth,
|
|
212
223
|
asChild = false,
|
|
213
224
|
loading = false,
|
|
@@ -215,27 +226,74 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
215
226
|
rightIcon,
|
|
216
227
|
children,
|
|
217
228
|
disabled,
|
|
218
|
-
|
|
229
|
+
onClick,
|
|
230
|
+
...props
|
|
219
231
|
}, ref) => {
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
// #649: `asChild` daha önce `asChild ? "button" : "button"` idi — ternary'nin iki
|
|
233
|
+
// dalı BİREBİR aynıydı, yani prop tipte ve docs'ta vaat edilmesine rağmen hiçbir
|
|
234
|
+
// şey yapmıyordu. Radix `Slot`a bağlandı (kardeş desen: breadcrumb #309, card #310).
|
|
235
|
+
//
|
|
236
|
+
// Slot TEK ve GEÇERLİ bir element çocuğu ister. `asChild` bugüne dek etkisiz
|
|
237
|
+
// olduğu için `<Button asChild>düz metin</Button>` yazan mevcut kullanım sorunsuz
|
|
238
|
+
// çalışıyordu; doğrudan Slot'a düşseydi sessizce BOŞ render ederdi. Bu yüzden
|
|
239
|
+
// çocuk element değilse native <button>'a geri düşülür.
|
|
240
|
+
const renderAsSlot = asChild && React.isValidElement(children);
|
|
241
|
+
const Comp = renderAsSlot ? Slot : "button";
|
|
242
|
+
const isDisabled = disabled || loading;
|
|
243
|
+
|
|
244
|
+
// asChild modunda çocuk <a>/<Link>/<span> olabilir; bu elemanlar native `disabled`
|
|
245
|
+
// özniteliğini TANIMAZ:
|
|
246
|
+
// • React onu geçersiz bir öznitelik olarak DOM'a basar,
|
|
247
|
+
// • `:disabled` hiç eşleşmez → cva'daki `disabled:*` stilleri ölü kalır,
|
|
248
|
+
// • yardımcı teknoloji "devre dışı" duyurusunu alamaz.
|
|
249
|
+
// Bu yüzden devre dışılık Slot dalında ARIA + veri özniteliği ile ifade edilir;
|
|
250
|
+
// native `disabled` yalnız gerçek <button> dalında verilir.
|
|
251
|
+
const disabledProps = renderAsSlot
|
|
252
|
+
? {
|
|
253
|
+
"aria-disabled": isDisabled || undefined,
|
|
254
|
+
"data-disabled": isDisabled ? "" : undefined,
|
|
255
|
+
}
|
|
256
|
+
: { disabled: isDisabled };
|
|
257
|
+
|
|
258
|
+
// asChild + devre dışı: `pointer-events-none` fareyi keser ama bir <a> klavyeden
|
|
259
|
+
// (Enter) hâlâ etkinleşir. Etkinleştirme burada durdurulur; `preventDefault`
|
|
260
|
+
// bağlantı gezinmesini de iptal eder.
|
|
261
|
+
// SINIR: Radix `Slot` olay işleyicilerini birleştirirken ÖNCE çocuğun kendi
|
|
262
|
+
// işleyicisini çağırır — çocuğa doğrudan bağlanmış bir `onClick` engellenemez.
|
|
263
|
+
const handleClick =
|
|
264
|
+
renderAsSlot && isDisabled
|
|
265
|
+
? (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
event.stopPropagation();
|
|
268
|
+
}
|
|
269
|
+
: onClick;
|
|
270
|
+
|
|
223
271
|
return (
|
|
224
272
|
<Comp
|
|
225
|
-
className={cn(
|
|
273
|
+
className={cn(
|
|
274
|
+
"moonui-theme",
|
|
275
|
+
buttonVariants({ variant, size, rounded, fullWidth, className }),
|
|
276
|
+
// `:disabled` eşleşmediği için görsel/etkileşimsel devre dışılık Slot
|
|
277
|
+
// dalında açık sınıflarla verilir.
|
|
278
|
+
renderAsSlot && isDisabled && "opacity-50 cursor-not-allowed pointer-events-none"
|
|
279
|
+
)}
|
|
226
280
|
ref={ref}
|
|
227
|
-
|
|
281
|
+
{...disabledProps}
|
|
228
282
|
// Görsel spinner + data-loading vardı ama yardımcı teknolojiye meşgul
|
|
229
283
|
// durumu hiç duyurulmuyordu.
|
|
230
284
|
aria-busy={loading || undefined}
|
|
231
285
|
data-loading={loading ? "" : undefined}
|
|
232
286
|
{...props}
|
|
287
|
+
onClick={handleClick}
|
|
233
288
|
>
|
|
234
289
|
{loading && (
|
|
235
290
|
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
|
|
236
291
|
)}
|
|
237
292
|
{!loading && leftIcon}
|
|
238
|
-
{
|
|
293
|
+
{/* #649: Slot dalında spinner ve ikonlar `Slottable` sayesinde çocuğun İÇİNE
|
|
294
|
+
taşınır; aksi hâlde Slot birden çok çocuk görüp `Children.only` ile patlardı
|
|
295
|
+
ve `loading`/ikon sözleşmesi asChild'da tamamen kaybolurdu. */}
|
|
296
|
+
{renderAsSlot ? <Slottable>{children}</Slottable> : children}
|
|
239
297
|
{!loading && rightIcon}
|
|
240
298
|
</Comp>
|
|
241
299
|
);
|
|
@@ -21,17 +21,71 @@ import { microInteractionVariants, hoverAnimations, tapAnimations } from "@/lib/
|
|
|
21
21
|
// • `asChild`: yalnız tipteydi, gövdede hiç okunmuyordu → `...props` ile native div'e
|
|
22
22
|
// sızıyor (`<div aschild="true">`, React uyarısı). Radix `Slot`a bağlandı.
|
|
23
23
|
|
|
24
|
+
// #646: cva TABANINDAKİ `dark:bg-gray-900` KALDIRILDI — `bg-card` token'ı zaten
|
|
25
|
+
// aynı dizede duruyordu, ikisi birden gereksiz bir İKİNCİ katmandı.
|
|
26
|
+
//
|
|
27
|
+
// NEDEN kaldırıldı (mekanizma): `tailwind-merge` yalnız AYNI değiştirici (modifier)
|
|
28
|
+
// kümesindeki sınıfları çakıştırır. Tüketicinin verdiği ÖNEKSİZ `bg-white/90`,
|
|
29
|
+
// `dark:` önekli `dark:bg-gray-900` ile aynı grupta görülmediği için ikisi birden
|
|
30
|
+
// hayatta kalıyor; koyu temada `.dark .dark\:bg-gray-900` daha yüksek özgüllükle
|
|
31
|
+
// kazanıyordu. Yani component, tüketicinin arka planını SESSİZCE eziyordu.
|
|
32
|
+
// Ölçüm (gradient-flow demosu, koyu tema): kart rgb(17,24,39)'de kalıyor, üstündeki
|
|
33
|
+
// `text-gray-900` metinler TAM 1.00:1 — matematiksel olarak görünmez. Kaldırıldıktan
|
|
34
|
+
// sonra `bg-white/90` kazanıyor: 1.00 → 14.46, 2.35 → 6.16, 1.72 → 8.40.
|
|
35
|
+
//
|
|
36
|
+
// GÖRSEL DELTA (tüketici override'ı YOKken): koyu temada varsayılan kart
|
|
37
|
+
// rgb(17,24,39) → rgb(3,7,17) (= `bg-card`). Kartın kendi metniyle kontrastı
|
|
38
|
+
// 16.12 → 18.31'e ÇIKAR; kenarlık (`dark:border-gray-800`) yüzeyden 1.17 → 1.35
|
|
39
|
+
// ile daha iyi ayrışır. Bu, `elevated` varyantının #310'dan beri zaten yayımladığı
|
|
40
|
+
// koyu yüzeyin ta kendisidir (`dark:bg-card`) — yani yeni bir görünüm değil.
|
|
41
|
+
// Açık temada hiçbir değişiklik yok (`dark:` sınıfı orada zaten uygulanmıyordu).
|
|
42
|
+
//
|
|
43
|
+
// #643 (TERS PARİTE — free geride): #646 sonrasında bu dosyada HÂLÂ 12 ham `gray-*`
|
|
44
|
+
// sınıfı duruyordu (issue "6" diyordu; yorumsuz gerçek sayım 12: 3+2+3+2+2), pro
|
|
45
|
+
// `card.tsx`te 0. Tümü token'a taşındı. Ölçümler (WCAG 2.1 kontrast oranı, önce→sonra):
|
|
46
|
+
//
|
|
47
|
+
// • `dark:text-gray-100` / `dark:text-gray-200` (4 yer) → KALDIRILDI.
|
|
48
|
+
// `text-card-foreground` zaten aynı dizede; `dark:` önekli sınıf onu ezmekle
|
|
49
|
+
// kalmıyor, #646'nın `dark:bg-gray-900` için belgelediği tuzağın aynısıyla
|
|
50
|
+
// TÜKETİCİNİN `text-*` override'ını da sessizce eziyordu (farklı değiştirici
|
|
51
|
+
// kümesi → `tailwind-merge` çakıştırmıyor, `.dark` özgüllüğü kazanıyor).
|
|
52
|
+
// Metin/yüzey kontrastı koyu temada 18.31 → 16.19 (AA 4.5'in 3.6 katı).
|
|
53
|
+
// • `dark:border-gray-800` → tabanda `border-border`. Koyu tema 1.37 → 1.36.
|
|
54
|
+
// BONUS: taban yalnız `border` (genişlik) taşıdığı için AÇIK temada kenar rengi
|
|
55
|
+
// token'dan değil Tailwind varsayılanı `gray-200`den geliyordu; artık token.
|
|
56
|
+
// Açık tema 1.24 → 1.23 (görsel olarak ayırt edilemez).
|
|
57
|
+
// • `outline` › `dark:border-gray-700` → KALDIRILDI (taban `border-border` yeterli).
|
|
58
|
+
// Koyu tema kenar/zemin 1.95 → 1.36. Bu bilinçli: `gray-700` sistemdeki TEK
|
|
59
|
+
// aykırı değerdi — input/popover/dialog dahil her component koyu temada zaten
|
|
60
|
+
// `--border`i (1.36) kullanıyor. tokens.css:163-166 `--border`i açıkça
|
|
61
|
+
// "dekoratif" ilan eder (metin eşiği uygulanmaz). Kart, sistemle hizalandı.
|
|
62
|
+
// • `filled` › `dark:bg-gray-800` → `dark:bg-muted`. Yüzey/sayfa-zemini AYRIŞMASI
|
|
63
|
+
// 1.37 → 1.49 (İYİLEŞTİ); metin/yüzey 13.34 → 10.87 (AA'nın 2.4 katı).
|
|
64
|
+
// #368 `--muted`i tam bu rol için ("DEKORATİF YÜZEY", koyu temada ayrışmalı)
|
|
65
|
+
// ayarlamıştı.
|
|
66
|
+
// ⚠ Pro'nun seçimi (`dark:bg-secondary`) BİLİNÇLİ OLARAK REDDEDİLDİ: koyu temada
|
|
67
|
+
// `--secondary` = rgb(2,8,23), sayfa zemini rgb(3,7,17) → ayrışma 1.01, yani
|
|
68
|
+
// `filled` yüzeyi fiilen GÖRÜNMEZ. Ham-gri ihlalini görünmez-yüzey ihlaliyle
|
|
69
|
+
// takas etmemek için (#545 dersi) pro yerine `--muted` alındı; pro `card.tsx`
|
|
70
|
+
// de aynı ölçümle `--muted`e çekildi.
|
|
71
|
+
// • `dark:shadow-gray-900/20`, `…/30`, `dark:shadow-gray-950/10` → aynı alfalarla
|
|
72
|
+
// `dark:shadow-black`. Gölge rengi; koyu zeminde ölçülebilir kontrast rolü yok,
|
|
73
|
+
// pro idiyomu. (Not: bu blokta bilerek ardışık eğik-yıldız yazılmaz — depodaki
|
|
74
|
+
// regex tabanlı tarayıcılar onu blok yorum başlangıcı sanıp gerisini yutuyor.)
|
|
75
|
+
// • `glass` › `dark:border-gray-700/30` → `dark:border-white/10` (kenar/cam yüzey
|
|
76
|
+
// 1.15 → 1.23, İYİLEŞTİ), `dark:bg-gray-800/10` → `dark:bg-black/10`
|
|
77
|
+
// (yüzey/zemin 1.02 → 1.01; cam varyantı zaten arkasındaki içeriği gösterir).
|
|
24
78
|
const cardVariants = cva(
|
|
25
|
-
"rounded-lg border bg-card text-card-foreground shadow-sm dark:shadow-
|
|
79
|
+
"rounded-lg border border-border bg-card text-card-foreground shadow-sm dark:shadow-black/20 transition-all duration-200",
|
|
26
80
|
{
|
|
27
81
|
variants: {
|
|
28
82
|
variant: {
|
|
29
83
|
default: "",
|
|
30
|
-
outline: "border border-border shadow-none bg-transparent dark:
|
|
31
|
-
filled: "border-none bg-secondary dark:bg-
|
|
32
|
-
elevated: "border-none shadow-md dark:shadow-lg dark:shadow-
|
|
84
|
+
outline: "border border-border shadow-none bg-transparent dark:bg-transparent",
|
|
85
|
+
filled: "border-none bg-secondary dark:bg-muted dark:shadow-inner dark:shadow-black/10",
|
|
86
|
+
elevated: "border-none shadow-md dark:shadow-lg dark:shadow-black/30 dark:bg-card",
|
|
33
87
|
gradient: "border-none bg-gradient-to-br from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/20",
|
|
34
|
-
glass: "border border-white/20 bg-white/10 backdrop-blur-md dark:border-
|
|
88
|
+
glass: "border border-white/20 bg-white/10 backdrop-blur-md dark:border-white/10 dark:bg-black/10",
|
|
35
89
|
},
|
|
36
90
|
size: {
|
|
37
91
|
md: "",
|
|
@@ -6,7 +6,91 @@ import { Check, ChevronRight, Circle } from "lucide-react"
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "../../lib/utils"
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/* #645 — Menü açıkken Tab hiçbir şey yapmıyordu.
|
|
10
|
+
*
|
|
11
|
+
* KÖK NEDEN (Radix kaynağı, `@radix-ui/react-menu` → `MenuContentImpl`):
|
|
12
|
+
* if (isKeyDownInside) { if (event.key === "Tab") event.preventDefault(); ... }
|
|
13
|
+
* Radix, Tab'ı bilinçli olarak yutuyor ("menus should not be navigated using tab
|
|
14
|
+
* key") ama karşılığında HİÇBİR ŞEY yapmıyor — menü ne kapanıyor ne odak
|
|
15
|
+
* ilerliyor. Üstelik `modal` menüde `FocusScope` da `trapped` olduğu için odak
|
|
16
|
+
* konteynerden çıkamıyor. Yani kusur sarmalayıcımızda değil, devraldığımız
|
|
17
|
+
* Radix davranışında.
|
|
18
|
+
*
|
|
19
|
+
* SEÇİLEN SEMANTİK: WAI-ARIA APG "menu button" — menü TEK bir tab durağıdır;
|
|
20
|
+
* Tab menüyü kapatır ve odağı tetikleyiciye döndürür (Escape ile aynı çıkış
|
|
21
|
+
* yolu, dolayısıyla ailede tutarlı). Alt menüde basılan Tab da kökü kapatır.
|
|
22
|
+
*
|
|
23
|
+
* Kapatma için köke kontrollü bir açık/kapalı durumu takılır; `close` yalnız bu
|
|
24
|
+
* dosyanın içinde, public export açılmadan paylaşılır (#667).
|
|
25
|
+
*/
|
|
26
|
+
const DropdownMenuCloseContext = React.createContext<(() => void) | null>(null)
|
|
27
|
+
|
|
28
|
+
type DropdownMenuRootProps = React.ComponentPropsWithoutRef<
|
|
29
|
+
typeof DropdownMenuPrimitive.Root
|
|
30
|
+
>
|
|
31
|
+
|
|
32
|
+
const DropdownMenu = ({
|
|
33
|
+
open,
|
|
34
|
+
defaultOpen,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
children,
|
|
37
|
+
...props
|
|
38
|
+
}: DropdownMenuRootProps) => {
|
|
39
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
|
|
40
|
+
defaultOpen ?? false
|
|
41
|
+
)
|
|
42
|
+
const isControlled = open !== undefined
|
|
43
|
+
const currentOpen = isControlled ? open : uncontrolledOpen
|
|
44
|
+
|
|
45
|
+
const handleOpenChange = React.useCallback(
|
|
46
|
+
(nextOpen: boolean) => {
|
|
47
|
+
if (!isControlled) setUncontrolledOpen(nextOpen)
|
|
48
|
+
onOpenChange?.(nextOpen)
|
|
49
|
+
},
|
|
50
|
+
[isControlled, onOpenChange]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const close = React.useCallback(() => handleOpenChange(false), [
|
|
54
|
+
handleOpenChange,
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<DropdownMenuCloseContext.Provider value={close}>
|
|
59
|
+
<DropdownMenuPrimitive.Root
|
|
60
|
+
{...props}
|
|
61
|
+
open={currentOpen}
|
|
62
|
+
onOpenChange={handleOpenChange}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</DropdownMenuPrimitive.Root>
|
|
66
|
+
</DropdownMenuCloseContext.Provider>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
DropdownMenu.displayName = "DropdownMenu"
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Menü içeriğinde Tab'ı WAI-ARIA menü sözleşmesine bağlayan ortak işleyici.
|
|
73
|
+
* Alt menülerden köpüren tuş olayları yok sayılır — onları kendi içerikleri ele alır.
|
|
74
|
+
*/
|
|
75
|
+
function useDropdownMenuTabDismiss(
|
|
76
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>
|
|
77
|
+
) {
|
|
78
|
+
const close = React.useContext(DropdownMenuCloseContext)
|
|
79
|
+
return (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
80
|
+
onKeyDown?.(event)
|
|
81
|
+
if (event.defaultPrevented) return
|
|
82
|
+
if (event.key !== "Tab") return
|
|
83
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
84
|
+
if (!close) return
|
|
85
|
+
const target = event.target as HTMLElement | null
|
|
86
|
+
if (target?.closest("[data-radix-menu-content]") !== event.currentTarget) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
// Odağı Radix'in `onCloseAutoFocus`'u tetikleyiciye geri taşır.
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
close()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
10
94
|
|
|
11
95
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
|
12
96
|
|
|
@@ -43,27 +127,36 @@ DropdownMenuSubTrigger.displayName =
|
|
|
43
127
|
const DropdownMenuSubContent = React.forwardRef<
|
|
44
128
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
45
129
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
46
|
-
>(({ className, ...props }, ref) =>
|
|
130
|
+
>(({ className, onKeyDown, ...props }, ref) => {
|
|
131
|
+
// #645: alt menüde Tab da kökü kapatır (WAI-ARIA: tüm ata menüler kapanır).
|
|
132
|
+
const handleKeyDown = useDropdownMenuTabDismiss(onKeyDown)
|
|
133
|
+
return (
|
|
47
134
|
<DropdownMenuPrimitive.SubContent
|
|
48
135
|
ref={ref}
|
|
136
|
+
onKeyDown={handleKeyDown}
|
|
49
137
|
className={cn(
|
|
50
138
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
51
139
|
className
|
|
52
140
|
)}
|
|
53
141
|
{...props}
|
|
54
142
|
/>
|
|
55
|
-
)
|
|
143
|
+
)
|
|
144
|
+
})
|
|
56
145
|
DropdownMenuSubContent.displayName =
|
|
57
146
|
DropdownMenuPrimitive.SubContent.displayName
|
|
58
147
|
|
|
59
148
|
const DropdownMenuContent = React.forwardRef<
|
|
60
149
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
61
150
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
62
|
-
>(({ className, sideOffset = 4, ...props }, ref) =>
|
|
151
|
+
>(({ className, sideOffset = 4, onKeyDown, ...props }, ref) => {
|
|
152
|
+
// #645: Tab menüyü kapatır, odak tetikleyiciye döner.
|
|
153
|
+
const handleKeyDown = useDropdownMenuTabDismiss(onKeyDown)
|
|
154
|
+
return (
|
|
63
155
|
<DropdownMenuPrimitive.Portal>
|
|
64
156
|
<DropdownMenuPrimitive.Content
|
|
65
157
|
ref={ref}
|
|
66
158
|
sideOffset={sideOffset}
|
|
159
|
+
onKeyDown={handleKeyDown}
|
|
67
160
|
className={cn(
|
|
68
161
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
69
162
|
className
|
|
@@ -71,7 +164,8 @@ const DropdownMenuContent = React.forwardRef<
|
|
|
71
164
|
{...props}
|
|
72
165
|
/>
|
|
73
166
|
</DropdownMenuPrimitive.Portal>
|
|
74
|
-
)
|
|
167
|
+
)
|
|
168
|
+
})
|
|
75
169
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
|
76
170
|
|
|
77
171
|
const DropdownMenuItem = React.forwardRef<
|
|
@@ -376,6 +376,10 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
|
|
|
376
376
|
<motion.div
|
|
377
377
|
className={cn(
|
|
378
378
|
"relative border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
|
379
|
+
// #661-2: dosya girdisi `opacity-0` ile tum alani kapliyor; klavyeyle
|
|
380
|
+
// Tab'landiginda HICBIR gorsel iz yoktu (WCAG 2.4.7). Odak halkasi
|
|
381
|
+
// sarmalayiciya `focus-within` ile bagli — kutunun kendisi odaklanamaz.
|
|
382
|
+
"focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
|
379
383
|
isDragOver && "border-primary bg-primary/5",
|
|
380
384
|
disabled && "opacity-50 cursor-not-allowed",
|
|
381
385
|
!disabled && "hover:border-primary/50 cursor-pointer"
|
|
@@ -363,7 +363,10 @@ export type {
|
|
|
363
363
|
} from "./select";
|
|
364
364
|
|
|
365
365
|
// Separator
|
|
366
|
-
|
|
366
|
+
// #643 (#667 ile açıldı) — TERS PARİTE kapatıldı: pro barrel `MoonUISeparatorProProps`i
|
|
367
|
+
// dışa açıyordu, free tarafta MoonUI önekli yüzey eksikti.
|
|
368
|
+
export { Separator as MoonUISeparator, separatorVariants as moonUISeparatorVariants } from "./separator";
|
|
369
|
+
export type { SeparatorProps as MoonUISeparatorProps } from "./separator";
|
|
367
370
|
|
|
368
371
|
// SimpleEditor
|
|
369
372
|
export { SimpleEditor as MoonUISimpleEditor } from "./simple-editor";
|
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* #664 (S9) — ÖLÜ DOSYA. Silinmedi; gerekçesi aşağıda. Silmeden ÖNCE bu notu okuyun.
|
|
5
|
+
*
|
|
6
|
+
* ── DURUM: bu dosya hiçbir yerden erişilemiyor ───────────────────────────────
|
|
7
|
+
* Dört kanıt (2026-08-28 ölçümü):
|
|
8
|
+
* 1. Barrel'da yok — `components/ui/index.ts`, `src/index.tsx` ve
|
|
9
|
+
* `popover.tsx` içinde `popover-pro` grep'i **0**.
|
|
10
|
+
* 2. Yayımlı yüzeyde yok — `packages/moonui/dist/index.d.ts` içinde
|
|
11
|
+
* `PopoverHeaderPro` / `PopoverContentProProps` **0**.
|
|
12
|
+
* 3. Tüketici yok — repo genelinde bu dosyayı import eden **0** modül.
|
|
13
|
+
* 4. Kanonik başka yerde — docs'un GERÇEKTEN kullandığı Pro popover
|
|
14
|
+
* `MoonUIPopoverPro`'dur ve
|
|
15
|
+
* `packages/moonui-pro/src/components/ui/popover.tsx`te
|
|
16
|
+
* yaşar (`src/docs-config/popover.config.tsx:47-51`
|
|
17
|
+
* oradan import eder; yayımlı moonui-pro dist'inde var).
|
|
18
|
+
*
|
|
19
|
+
* ── NEDEN EXPORT EDİLMİYOR (kalıcı hüküm) ────────────────────────────────────
|
|
20
|
+
* Export etmek kusuru P3'ten P2'ye YÜKSELTİR: `customBackdrop` (:~215) ve
|
|
21
|
+
* `focusTrap` (:~219) ilan edilip varsayılanlarıyla destructure ediliyor ama
|
|
22
|
+
* gövdede HİÇ okunmuyor — public API'ye iki ölü prop eklemek olurdu.
|
|
23
|
+
* Ayrıca #667 registry kilidi: yayımlanmamış bir export eklenirse registry
|
|
24
|
+
* *tazelik* ya da *tutarlılık* kapısından biri mutlaka kırmızıya döner.
|
|
25
|
+
*
|
|
26
|
+
* ── NEDEN BU KOŞUDA SİLİNMEDİ (gerekçe — "zahmetli" DEĞİL) ───────────────────
|
|
27
|
+
* `scripts/lib/free-registry.js:285-288` free registry'yi diskteki dosya
|
|
28
|
+
* listesinden üretir ve barrel'da olmayan her modülü `excluded.notExported`
|
|
29
|
+
* altına yazar. Bu dosya şu an `free-registry.generated.json:27-29`ta öyle
|
|
30
|
+
* duruyor. Dosyayı silmek üretilen JSON'u ZORUNLU olarak değiştirir →
|
|
31
|
+
* `npm run check:free-registry` kırmızıya döner ve düzeltmenin tek yolu
|
|
32
|
+
* registry çıktısını yeniden üretmektir. Bu koşunun kısıtı registry generated
|
|
33
|
+
* dosyalarını yeniden üretmeyi yasaklıyor.
|
|
34
|
+
*
|
|
35
|
+
* ── YAPILACAK: #667 yayın kontrol listesinde sil ─────────────────────────────
|
|
36
|
+
* #667 zaten bump anında `build:free-registry` koşup çıktıyı commit'lemeyi
|
|
37
|
+
* planlıyor. Silme O ADIMLA BİRLİKTE yapılmalı — tek komut:
|
|
38
|
+
* git rm packages/moonui/src/components/ui/popover-pro.tsx
|
|
39
|
+
* npm run build:free-registry
|
|
40
|
+
* Silme kararı teknik olarak ONAYLI; bekleyen tek şey registry'yi yeniden
|
|
41
|
+
* üretme iznidir.
|
|
42
|
+
*/
|
|
43
|
+
|
|
3
44
|
import * as React from "react"
|
|
4
45
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|
5
46
|
import { motion, AnimatePresence, useSpring, useTransform } from "framer-motion"
|
|
@@ -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
|
/**
|