@moontra/moonui 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+
5
+ import { ToggleGroup, ToggleGroupItem } from '../toggle-group'
6
+
7
+ describe('ToggleGroup Components', () => {
8
+ describe('Rendering', () => {
9
+ it('renders group with items', () => {
10
+ render(
11
+ <ToggleGroup type="single" data-testid="group">
12
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
13
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
14
+ </ToggleGroup>
15
+ )
16
+
17
+ const group = screen.getByTestId('group')
18
+ expect(group).toBeInTheDocument()
19
+ expect(screen.getByText('A')).toBeInTheDocument()
20
+ expect(screen.getByText('B')).toBeInTheDocument()
21
+ })
22
+
23
+ it('applies base + custom className on the root', () => {
24
+ render(
25
+ <ToggleGroup type="single" className="custom-group" data-testid="group">
26
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
27
+ </ToggleGroup>
28
+ )
29
+
30
+ const group = screen.getByTestId('group')
31
+ // moonui-theme + FREE token/layout base sınıfları korunur
32
+ expect(group).toHaveClass('moonui-theme', 'inline-flex', 'items-center', 'gap-1')
33
+ expect(group).toHaveClass('custom-group')
34
+ })
35
+
36
+ it('applies custom className on an item alongside toggleVariants', () => {
37
+ render(
38
+ <ToggleGroup type="single">
39
+ <ToggleGroupItem value="a" className="custom-item">
40
+ A
41
+ </ToggleGroupItem>
42
+ </ToggleGroup>
43
+ )
44
+
45
+ const item = screen.getByText('A')
46
+ expect(item).toHaveClass('custom-item')
47
+ // toggleVariants'tan gelen aktif-durum token sınıfı
48
+ expect(item).toHaveClass('data-[state=on]:bg-accent')
49
+ })
50
+
51
+ it('forwards ref on the group', () => {
52
+ const ref = React.createRef<HTMLDivElement>()
53
+ render(
54
+ <ToggleGroup type="single" ref={ref}>
55
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
56
+ </ToggleGroup>
57
+ )
58
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
59
+ })
60
+
61
+ it('forwards ref on the item', () => {
62
+ const ref = React.createRef<HTMLButtonElement>()
63
+ render(
64
+ <ToggleGroup type="single">
65
+ <ToggleGroupItem value="a" ref={ref}>
66
+ A
67
+ </ToggleGroupItem>
68
+ </ToggleGroup>
69
+ )
70
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement)
71
+ })
72
+
73
+ it('maintains displayNames', () => {
74
+ expect(ToggleGroup.displayName).toBe('ToggleGroup')
75
+ expect(ToggleGroupItem.displayName).toBe('ToggleGroupItem')
76
+ })
77
+ })
78
+
79
+ describe('Single selection (type="single")', () => {
80
+ it('marks exactly one item active via defaultValue', () => {
81
+ render(
82
+ <ToggleGroup type="single" defaultValue="b">
83
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
84
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
85
+ </ToggleGroup>
86
+ )
87
+
88
+ expect(screen.getByText('A')).toHaveAttribute('data-state', 'off')
89
+ expect(screen.getByText('B')).toHaveAttribute('data-state', 'on')
90
+ })
91
+
92
+ it('respects controlled value', () => {
93
+ render(
94
+ <ToggleGroup type="single" value="a">
95
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
96
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
97
+ </ToggleGroup>
98
+ )
99
+
100
+ expect(screen.getByText('A')).toHaveAttribute('data-state', 'on')
101
+ expect(screen.getByText('B')).toHaveAttribute('data-state', 'off')
102
+ })
103
+
104
+ it('fires onValueChange with a string on click', () => {
105
+ const onValueChange = jest.fn()
106
+ render(
107
+ <ToggleGroup type="single" onValueChange={onValueChange}>
108
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
109
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
110
+ </ToggleGroup>
111
+ )
112
+
113
+ fireEvent.click(screen.getByText('B'))
114
+ expect(onValueChange).toHaveBeenCalledWith('b')
115
+ })
116
+ })
117
+
118
+ describe('Multiple selection (type="multiple")', () => {
119
+ it('marks multiple items active via defaultValue', () => {
120
+ render(
121
+ <ToggleGroup type="multiple" defaultValue={['a', 'b']}>
122
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
123
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
124
+ <ToggleGroupItem value="c">C</ToggleGroupItem>
125
+ </ToggleGroup>
126
+ )
127
+
128
+ expect(screen.getByText('A')).toHaveAttribute('data-state', 'on')
129
+ expect(screen.getByText('B')).toHaveAttribute('data-state', 'on')
130
+ expect(screen.getByText('C')).toHaveAttribute('data-state', 'off')
131
+ })
132
+
133
+ it('fires onValueChange with an array on click', () => {
134
+ const onValueChange = jest.fn()
135
+ render(
136
+ <ToggleGroup type="multiple" defaultValue={['a']} onValueChange={onValueChange}>
137
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
138
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
139
+ </ToggleGroup>
140
+ )
141
+
142
+ fireEvent.click(screen.getByText('B'))
143
+ expect(onValueChange).toHaveBeenCalledWith(['a', 'b'])
144
+ })
145
+ })
146
+
147
+ describe('Variant / size context propagation', () => {
148
+ it('propagates group variant + size to items', () => {
149
+ render(
150
+ <ToggleGroup type="single" variant="outline" size="lg">
151
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
152
+ </ToggleGroup>
153
+ )
154
+
155
+ const item = screen.getByText('A')
156
+ // outline variant + lg size (toggleVariants ile birebir)
157
+ expect(item).toHaveClass('border', 'border-input')
158
+ expect(item).toHaveClass('h-11', 'px-5')
159
+ })
160
+
161
+ it('uses item prop when the group provides no variant/size', () => {
162
+ render(
163
+ <ToggleGroup type="single">
164
+ <ToggleGroupItem value="a" variant="outline" size="sm">
165
+ A
166
+ </ToggleGroupItem>
167
+ </ToggleGroup>
168
+ )
169
+
170
+ const item = screen.getByText('A')
171
+ // context tanımsız → item prop devreye girer
172
+ expect(item).toHaveClass('border', 'border-input')
173
+ expect(item).toHaveClass('h-9', 'px-2.5')
174
+ })
175
+
176
+ it('group variant overrides item variant (context wins)', () => {
177
+ render(
178
+ <ToggleGroup type="single" variant="outline">
179
+ <ToggleGroupItem value="a" variant="default">
180
+ A
181
+ </ToggleGroupItem>
182
+ </ToggleGroup>
183
+ )
184
+
185
+ const item = screen.getByText('A')
186
+ // context.variant ?? props.variant → outline kazanır
187
+ expect(item).toHaveClass('border', 'border-input')
188
+ })
189
+
190
+ it('falls back to default variant/size when nothing is set', () => {
191
+ render(
192
+ <ToggleGroup type="single">
193
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
194
+ </ToggleGroup>
195
+ )
196
+
197
+ const item = screen.getByText('A')
198
+ // default variant: bg-transparent, default size: h-10 px-3
199
+ expect(item).toHaveClass('bg-transparent', 'h-10', 'px-3')
200
+ })
201
+ })
202
+
203
+ describe('Disabled state', () => {
204
+ it('disables all items when the group is disabled', () => {
205
+ render(
206
+ <ToggleGroup type="single" disabled>
207
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
208
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
209
+ </ToggleGroup>
210
+ )
211
+
212
+ expect(screen.getByText('A')).toBeDisabled()
213
+ expect(screen.getByText('B')).toBeDisabled()
214
+ })
215
+
216
+ it('disables an individual item', () => {
217
+ const onValueChange = jest.fn()
218
+ render(
219
+ <ToggleGroup type="single" onValueChange={onValueChange}>
220
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
221
+ <ToggleGroupItem value="b" disabled>
222
+ B
223
+ </ToggleGroupItem>
224
+ </ToggleGroup>
225
+ )
226
+
227
+ const disabledItem = screen.getByText('B')
228
+ expect(disabledItem).toBeDisabled()
229
+ fireEvent.click(disabledItem)
230
+ expect(onValueChange).not.toHaveBeenCalled()
231
+ })
232
+ })
233
+
234
+ describe('Accessibility (Radix-provided)', () => {
235
+ it('single group exposes radiogroup + radio roles', () => {
236
+ render(
237
+ <ToggleGroup type="single" data-testid="group">
238
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
239
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
240
+ </ToggleGroup>
241
+ )
242
+
243
+ expect(screen.getByTestId('group')).toHaveAttribute('role', 'radiogroup')
244
+ expect(screen.getAllByRole('radio')).toHaveLength(2)
245
+ })
246
+
247
+ it('single active item reflects aria-checked', () => {
248
+ render(
249
+ <ToggleGroup type="single" defaultValue="a">
250
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
251
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
252
+ </ToggleGroup>
253
+ )
254
+
255
+ expect(screen.getByText('A')).toHaveAttribute('aria-checked', 'true')
256
+ expect(screen.getByText('B')).toHaveAttribute('aria-checked', 'false')
257
+ })
258
+
259
+ it('multiple group exposes toolbar role + aria-pressed items', () => {
260
+ render(
261
+ <ToggleGroup type="multiple" defaultValue={['a']} data-testid="group">
262
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
263
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
264
+ </ToggleGroup>
265
+ )
266
+
267
+ expect(screen.getByTestId('group')).toHaveAttribute('role', 'toolbar')
268
+ expect(screen.getByText('A')).toHaveAttribute('aria-pressed', 'true')
269
+ expect(screen.getByText('B')).toHaveAttribute('aria-pressed', 'false')
270
+ })
271
+
272
+ it('renders items as focusable buttons with Radix roving tabindex', () => {
273
+ render(
274
+ <ToggleGroup type="single" defaultValue="a">
275
+ <ToggleGroupItem value="a">A</ToggleGroupItem>
276
+ <ToggleGroupItem value="b">B</ToggleGroupItem>
277
+ </ToggleGroup>
278
+ )
279
+
280
+ const first = screen.getByText('A')
281
+ const second = screen.getByText('B')
282
+
283
+ // Radix RovingFocusGroup her item'a bir tabindex atar (roving nav altyapısı).
284
+ // Ok-tuşuyla odak taşıma gerçek tarayıcıda Radix tarafından sağlanır;
285
+ // jsdom odak olaylarını tam simüle etmediği için burada odaklanabilirlik doğrulanır.
286
+ expect(first).toHaveAttribute('tabindex')
287
+ expect(second).toHaveAttribute('tabindex')
288
+
289
+ first.focus()
290
+ expect(first).toHaveFocus()
291
+ })
292
+ })
293
+ })
@@ -0,0 +1,125 @@
1
+ "use client"
2
+
3
+ import * as React from "react";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { cn } from "../../lib/utils";
6
+ import { Button, type ButtonProps } from "./button";
7
+
8
+ /**
9
+ * Premium ButtonGroup Component
10
+ *
11
+ * Bitişik (segmentli) veya boşluklu buton grupları için yüksek kaliteli,
12
+ * erişilebilir bir kapsayıcı bileşen. Yatay/dikey yerleşim, bitişik kenar
13
+ * radius birleştirme ve çocuk `<Button>`'lara size/variant yayılımı sunar.
14
+ *
15
+ * Tamamen token-tabanlıdır: kenarlık ve köşe yuvarlama değerleri mevcut Button
16
+ * token'larından gelir; hardcoded renk YOKtur.
17
+ */
18
+ const buttonGroupVariants = cva(
19
+ // Temel kapsayıcı: inline-flex; çocuklar akış içinde hizalanır
20
+ "moonui-theme inline-flex",
21
+ {
22
+ variants: {
23
+ orientation: {
24
+ // Yatay: butonlar dikeyde ortalanır (içerik-genişliği korunur)
25
+ horizontal: "flex-row items-center",
26
+ // Dikey: butonlar aynı genişliğe uzar (items-stretch) → kenarlar hizalanır
27
+ vertical: "flex-col items-stretch",
28
+ },
29
+ attached: {
30
+ // Bitişik: boşluk yok; radius birleştirme compoundVariants ile uygulanır
31
+ true: "",
32
+ // Boşluklu: normal aralık, radius korunur
33
+ false: "gap-2",
34
+ },
35
+ },
36
+ compoundVariants: [
37
+ {
38
+ // Yatay + bitişik: bitişik yatay kenarlar düzleşir, -ml-px ile kenarlık örtüşür
39
+ orientation: "horizontal",
40
+ attached: true,
41
+ class: [
42
+ "[&>*:not(:first-child)]:rounded-l-none",
43
+ "[&>*:not(:last-child)]:rounded-r-none",
44
+ "[&>*:not(:first-child)]:-ml-px",
45
+ ],
46
+ },
47
+ {
48
+ // Dikey + bitişik: bitişik dikey kenarlar düzleşir, -mt-px ile kenarlık örtüşür
49
+ orientation: "vertical",
50
+ attached: true,
51
+ class: [
52
+ "[&>*:not(:first-child)]:rounded-t-none",
53
+ "[&>*:not(:last-child)]:rounded-b-none",
54
+ "[&>*:not(:first-child)]:-mt-px",
55
+ ],
56
+ },
57
+ ],
58
+ defaultVariants: {
59
+ orientation: "horizontal",
60
+ attached: true,
61
+ },
62
+ }
63
+ );
64
+
65
+ // ButtonGroup component props
66
+ export interface ButtonGroupProps
67
+ extends React.HTMLAttributes<HTMLDivElement>,
68
+ VariantProps<typeof buttonGroupVariants> {
69
+ /** Çocuk Button'lara yayılacak boyut (yalnızca çocuk kendi size'ını vermemişse) */
70
+ size?: ButtonProps["size"];
71
+ /** Çocuk Button'lara yayılacak varyant (yalnızca çocuk kendi variant'ını vermemişse) */
72
+ variant?: ButtonProps["variant"];
73
+ }
74
+
75
+ /**
76
+ * Premium ButtonGroup Component
77
+ *
78
+ * @param props - ButtonGroup bileşeni özellikleri
79
+ * @param props.orientation - Yerleşim yönü ("horizontal" | "vertical"), varsayılan "horizontal"
80
+ * @param props.attached - Bitişik (segmentli) mi yoksa boşluklu mu, varsayılan true
81
+ * @param props.size - Çocuk Button'lara yayılacak boyut (opsiyonel)
82
+ * @param props.variant - Çocuk Button'lara yayılacak varyant (opsiyonel)
83
+ */
84
+ const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
85
+ (
86
+ { className, orientation, attached, size, variant, children, ...props },
87
+ ref
88
+ ) => {
89
+ // size/variant yalnızca en az biri tanımlıysa çocuklara enjekte edilir
90
+ const shouldPropagate = size !== undefined || variant !== undefined;
91
+
92
+ const enhancedChildren = shouldPropagate
93
+ ? React.Children.map(children, (child) => {
94
+ // Geçersiz element (string/number/null) veya Button olmayan çocuklar
95
+ // GRACEFUL geçilir — geçersiz prop enjekte edilmez
96
+ if (!React.isValidElement(child) || child.type !== Button) {
97
+ return child;
98
+ }
99
+
100
+ const childProps = child.props as ButtonProps;
101
+
102
+ // Çocuğun kendi prop'u önceliklidir (child.props.size ?? size)
103
+ return React.cloneElement(child as React.ReactElement<ButtonProps>, {
104
+ size: childProps.size ?? size,
105
+ variant: childProps.variant ?? variant,
106
+ });
107
+ })
108
+ : children;
109
+
110
+ return (
111
+ <div
112
+ ref={ref}
113
+ role="group"
114
+ className={cn(buttonGroupVariants({ orientation, attached }), className)}
115
+ {...props}
116
+ >
117
+ {enhancedChildren}
118
+ </div>
119
+ );
120
+ }
121
+ );
122
+
123
+ ButtonGroup.displayName = "ButtonGroup";
124
+
125
+ export { ButtonGroup, buttonGroupVariants };
@@ -424,6 +424,44 @@ export {
424
424
  TooltipProvider as MoonUITooltipProvider,
425
425
  } from "./tooltip";
426
426
 
427
+ // Kbd (issue #247) — standalone klavye-tuşu rozeti
428
+ export {
429
+ Kbd as MoonUIKbd,
430
+ kbdVariants as moonUIKbdVariants,
431
+ } from "./kbd";
432
+ export type { KbdProps as MoonUIKbdProps } from "./kbd";
433
+
434
+ // Rating (issue #247) — standalone yıldız değerlendirme
435
+ export {
436
+ Rating as MoonUIRating,
437
+ ratingVariants as moonUIRatingVariants,
438
+ } from "./rating";
439
+ export type { RatingProps as MoonUIRatingProps } from "./rating";
440
+
441
+ // Spinner (issue #247) — standalone yükleme göstergesi
442
+ export {
443
+ Spinner as MoonUISpinner,
444
+ spinnerVariants as moonUISpinnerVariants,
445
+ } from "./spinner";
446
+ export type { SpinnerProps as MoonUISpinnerProps } from "./spinner";
447
+
448
+ // Button Group (issue #257) — bitişik/segmentli buton grupları
449
+ export {
450
+ ButtonGroup as MoonUIButtonGroup,
451
+ buttonGroupVariants as moonUIButtonGroupVariants,
452
+ } from "./button-group";
453
+ export type { ButtonGroupProps as MoonUIButtonGroupProps } from "./button-group";
454
+
455
+ // Toggle Group (issue #257) — tek/çoklu seçim segmented toggle seti
456
+ export {
457
+ ToggleGroup as MoonUIToggleGroup,
458
+ ToggleGroupItem as MoonUIToggleGroupItem,
459
+ } from "./toggle-group";
460
+ export type {
461
+ ToggleGroupProps as MoonUIToggleGroupProps,
462
+ ToggleGroupItemProps as MoonUIToggleGroupItemProps,
463
+ } from "./toggle-group";
464
+
427
465
  // Also export without MoonUI prefix for backward compatibility
428
466
  export * from "./accordion";
429
467
  export * from "./alert";
@@ -473,3 +511,8 @@ export * from "./textarea";
473
511
  export * from "./toast";
474
512
  export * from "./toggle";
475
513
  export * from "./tooltip";
514
+ export * from "./kbd";
515
+ export * from "./rating";
516
+ export * from "./spinner";
517
+ export * from "./button-group";
518
+ export * from "./toggle-group";
@@ -0,0 +1,66 @@
1
+ "use client"
2
+
3
+ import * as React from "react";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ /**
8
+ * Premium Kbd Component
9
+ *
10
+ * Klavye tuşlarını (⌘, Ctrl, K vb.) görsel olarak temsil eden standalone
11
+ * bir rozet bileşeni. Dark ve light modda uyumlu, token tabanlı stiller
12
+ * kullanır ve hafif kabartma/gölge ile fiziksel bir tuş görünümü verir.
13
+ */
14
+ const kbdVariants = cva(
15
+ [
16
+ // Temel klavye-tuşu görünümü — token tabanlı, hardcoded renk yok
17
+ "inline-flex items-center justify-center rounded",
18
+ "border border-border bg-muted",
19
+ "px-1.5 font-mono font-medium text-muted-foreground",
20
+ // Alt kenarda hafif kabartma (border token'ı ile — [#...] hex sınıfı DEĞİL)
21
+ "shadow-[0_1px_0_1px_hsl(var(--border))]",
22
+ "select-none whitespace-nowrap",
23
+ ],
24
+ {
25
+ variants: {
26
+ size: {
27
+ sm: "h-5 min-w-5 text-[10px]",
28
+ md: "h-6 min-w-6 text-xs",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ size: "md",
33
+ },
34
+ }
35
+ );
36
+
37
+ export interface KbdProps
38
+ extends React.HTMLAttributes<HTMLElement>,
39
+ VariantProps<typeof kbdVariants> {
40
+ /** Tuş içeriği (⌘, K, Ctrl vb.) */
41
+ children: React.ReactNode;
42
+ }
43
+
44
+ /**
45
+ * Premium Kbd Component
46
+ *
47
+ * @param props - Kbd bileşeni özellikleri
48
+ * @param props.size - Tuş boyutu ("sm" | "md")
49
+ * @param props.children - Tuş içeriği (⌘, K, Ctrl vb.)
50
+ */
51
+ const Kbd = React.forwardRef<HTMLElement, KbdProps>(
52
+ ({ className, size, children, ...props }, ref) => {
53
+ return (
54
+ <kbd
55
+ ref={ref}
56
+ className={cn("moonui-theme", kbdVariants({ size }), className)}
57
+ {...props}
58
+ >
59
+ {children}
60
+ </kbd>
61
+ );
62
+ }
63
+ );
64
+ Kbd.displayName = "Kbd";
65
+
66
+ export { Kbd, kbdVariants };