@moontra/moonui 3.1.0 → 3.2.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,218 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { Rating, ratingVariants } from '../rating'
5
+
6
+ describe('Rating Component', () => {
7
+ describe('Rendering', () => {
8
+ it('renders a radiogroup with the default aria-label', () => {
9
+ render(<Rating />)
10
+ const group = screen.getByRole('radiogroup')
11
+ expect(group).toBeInTheDocument()
12
+ expect(group).toHaveAttribute('aria-label', 'Rating')
13
+ })
14
+
15
+ it('renders the default number of stars (5)', () => {
16
+ const { container } = render(<Rating />)
17
+ const stars = container.querySelectorAll('[data-slot="rating-star"]')
18
+ expect(stars).toHaveLength(5)
19
+ })
20
+
21
+ it('renders a custom number of stars via max', () => {
22
+ const { container } = render(<Rating max={10} />)
23
+ const stars = container.querySelectorAll('[data-slot="rating-star"]')
24
+ expect(stars).toHaveLength(10)
25
+ })
26
+
27
+ it('renders one radio option per star in full precision', () => {
28
+ render(<Rating max={5} />)
29
+ expect(screen.getAllByRole('radio')).toHaveLength(5)
30
+ })
31
+
32
+ it('renders two radio options per star in half precision', () => {
33
+ render(<Rating max={5} precision="half" />)
34
+ expect(screen.getAllByRole('radio')).toHaveLength(10)
35
+ })
36
+
37
+ it('applies a custom className to the root', () => {
38
+ render(<Rating className="custom-rating" aria-label="Score" />)
39
+ expect(screen.getByRole('radiogroup')).toHaveClass('custom-rating')
40
+ })
41
+
42
+ it('respects a user-provided aria-label', () => {
43
+ render(<Rating aria-label="Product rating" />)
44
+ expect(screen.getByRole('radiogroup')).toHaveAttribute('aria-label', 'Product rating')
45
+ })
46
+
47
+ it('forwards ref to the root div', () => {
48
+ const ref = React.createRef<HTMLDivElement>()
49
+ render(<Rating ref={ref} />)
50
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
51
+ })
52
+
53
+ it('maintains displayName', () => {
54
+ expect(Rating.displayName).toBe('Rating')
55
+ })
56
+ })
57
+
58
+ describe('Sizes', () => {
59
+ it('applies default (md) size gap', () => {
60
+ render(<Rating />)
61
+ expect(screen.getByRole('radiogroup')).toHaveClass('gap-1')
62
+ })
63
+
64
+ it('applies sm size gap', () => {
65
+ render(<Rating size="sm" />)
66
+ expect(screen.getByRole('radiogroup')).toHaveClass('gap-0.5')
67
+ })
68
+
69
+ it('applies lg size gap', () => {
70
+ render(<Rating size="lg" />)
71
+ expect(screen.getByRole('radiogroup')).toHaveClass('gap-1.5')
72
+ })
73
+
74
+ it('exposes ratingVariants helper', () => {
75
+ expect(typeof ratingVariants).toBe('function')
76
+ expect(ratingVariants({ size: 'lg' })).toContain('gap-1.5')
77
+ })
78
+ })
79
+
80
+ describe('Interaction', () => {
81
+ it('calls onValueChange with the clicked star value', () => {
82
+ const onValueChange = jest.fn()
83
+ render(<Rating onValueChange={onValueChange} />)
84
+ fireEvent.click(screen.getByRole('radio', { name: '3 stars' }))
85
+ expect(onValueChange).toHaveBeenCalledWith(3)
86
+ })
87
+
88
+ it('uses singular label for the first star', () => {
89
+ const onValueChange = jest.fn()
90
+ render(<Rating onValueChange={onValueChange} />)
91
+ fireEvent.click(screen.getByRole('radio', { name: '1 star' }))
92
+ expect(onValueChange).toHaveBeenCalledWith(1)
93
+ })
94
+
95
+ it('updates uncontrolled value on click (defaultValue)', () => {
96
+ render(<Rating defaultValue={2} />)
97
+ // 2 stars initially checked
98
+ expect(screen.getByRole('radio', { name: '2 stars' })).toHaveAttribute('aria-checked', 'true')
99
+ fireEvent.click(screen.getByRole('radio', { name: '4 stars' }))
100
+ expect(screen.getByRole('radio', { name: '4 stars' })).toHaveAttribute('aria-checked', 'true')
101
+ expect(screen.getByRole('radio', { name: '2 stars' })).toHaveAttribute('aria-checked', 'false')
102
+ })
103
+
104
+ it('supports half precision selection', () => {
105
+ const onValueChange = jest.fn()
106
+ render(<Rating precision="half" onValueChange={onValueChange} />)
107
+ fireEvent.click(screen.getByRole('radio', { name: '2.5 stars' }))
108
+ expect(onValueChange).toHaveBeenCalledWith(2.5)
109
+ })
110
+ })
111
+
112
+ describe('Controlled behavior', () => {
113
+ it('reflects the controlled value prop', () => {
114
+ render(<Rating value={4} />)
115
+ expect(screen.getByRole('radio', { name: '4 stars' })).toHaveAttribute('aria-checked', 'true')
116
+ expect(screen.getByRole('radio', { name: '3 stars' })).toHaveAttribute('aria-checked', 'false')
117
+ })
118
+
119
+ it('does not change the controlled value internally on click but fires callback', () => {
120
+ const onValueChange = jest.fn()
121
+ render(<Rating value={2} onValueChange={onValueChange} />)
122
+ fireEvent.click(screen.getByRole('radio', { name: '5 stars' }))
123
+ // callback fired
124
+ expect(onValueChange).toHaveBeenCalledWith(5)
125
+ // but the displayed selection stays controlled at 2
126
+ expect(screen.getByRole('radio', { name: '2 stars' })).toHaveAttribute('aria-checked', 'true')
127
+ expect(screen.getByRole('radio', { name: '5 stars' })).toHaveAttribute('aria-checked', 'false')
128
+ })
129
+ })
130
+
131
+ describe('Keyboard navigation', () => {
132
+ it('increments value with ArrowRight', () => {
133
+ const onValueChange = jest.fn()
134
+ render(<Rating defaultValue={3} onValueChange={onValueChange} />)
135
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
136
+ expect(onValueChange).toHaveBeenCalledWith(4)
137
+ })
138
+
139
+ it('decrements value with ArrowLeft', () => {
140
+ const onValueChange = jest.fn()
141
+ render(<Rating defaultValue={3} onValueChange={onValueChange} />)
142
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowLeft' })
143
+ expect(onValueChange).toHaveBeenCalledWith(2)
144
+ })
145
+
146
+ it('sets minimum with Home and maximum with End', () => {
147
+ const onValueChange = jest.fn()
148
+ render(<Rating defaultValue={3} onValueChange={onValueChange} />)
149
+ const group = screen.getByRole('radiogroup')
150
+ fireEvent.keyDown(group, { key: 'Home' })
151
+ expect(onValueChange).toHaveBeenCalledWith(1)
152
+ fireEvent.keyDown(group, { key: 'End' })
153
+ expect(onValueChange).toHaveBeenCalledWith(5)
154
+ })
155
+
156
+ it('clamps at max on ArrowRight', () => {
157
+ const onValueChange = jest.fn()
158
+ render(<Rating defaultValue={5} onValueChange={onValueChange} />)
159
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
160
+ expect(onValueChange).toHaveBeenCalledWith(5)
161
+ })
162
+
163
+ it('steps by 0.5 in half precision', () => {
164
+ const onValueChange = jest.fn()
165
+ render(<Rating defaultValue={3} precision="half" onValueChange={onValueChange} />)
166
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
167
+ expect(onValueChange).toHaveBeenCalledWith(3.5)
168
+ })
169
+
170
+ it('is focusable when interactive', () => {
171
+ render(<Rating />)
172
+ expect(screen.getByRole('radiogroup')).toHaveAttribute('tabindex', '0')
173
+ })
174
+ })
175
+
176
+ describe('ReadOnly mode', () => {
177
+ it('marks the group as aria-readonly and non-focusable', () => {
178
+ render(<Rating value={3} readOnly />)
179
+ const group = screen.getByRole('radiogroup')
180
+ expect(group).toHaveAttribute('aria-readonly', 'true')
181
+ expect(group).not.toHaveAttribute('tabindex')
182
+ })
183
+
184
+ it('renders no interactive radio options', () => {
185
+ render(<Rating value={3} readOnly />)
186
+ expect(screen.queryAllByRole('radio')).toHaveLength(0)
187
+ })
188
+
189
+ it('does not respond to keyboard input', () => {
190
+ const onValueChange = jest.fn()
191
+ render(<Rating value={3} readOnly onValueChange={onValueChange} />)
192
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
193
+ expect(onValueChange).not.toHaveBeenCalled()
194
+ })
195
+
196
+ it('still renders the star visuals', () => {
197
+ const { container } = render(<Rating value={3} readOnly />)
198
+ expect(container.querySelectorAll('[data-slot="rating-star"]')).toHaveLength(5)
199
+ })
200
+ })
201
+
202
+ describe('Custom icon', () => {
203
+ it('renders a custom icon instead of the default Star', () => {
204
+ const CustomIcon = <svg data-testid="custom-icon" />
205
+ const { container } = render(<Rating icon={CustomIcon} max={3} />)
206
+ // Both empty and filled layers per star -> 2 icons per star
207
+ expect(container.querySelectorAll('[data-testid="custom-icon"]').length).toBeGreaterThanOrEqual(3)
208
+ })
209
+ })
210
+
211
+ describe('HTML attributes', () => {
212
+ it('passes through arbitrary HTML attributes', () => {
213
+ render(<Rating data-testid="my-rating" id="rating-1" />)
214
+ const group = screen.getByTestId('my-rating')
215
+ expect(group).toHaveAttribute('id', 'rating-1')
216
+ })
217
+ })
218
+ })
@@ -0,0 +1,141 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { Spinner, spinnerVariants } from '../spinner'
5
+
6
+ describe('Spinner Component', () => {
7
+ describe('Rendering', () => {
8
+ it('renders correctly with default props', () => {
9
+ render(<Spinner data-testid="spinner" />)
10
+ const spinner = screen.getByTestId('spinner')
11
+ expect(spinner).toBeInTheDocument()
12
+ })
13
+
14
+ it('applies custom className', () => {
15
+ render(<Spinner className="custom-spinner" data-testid="spinner" />)
16
+ expect(screen.getByTestId('spinner')).toHaveClass('custom-spinner')
17
+ })
18
+
19
+ it('forwards ref correctly', () => {
20
+ const ref = React.createRef<HTMLDivElement>()
21
+ render(<Spinner ref={ref} />)
22
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
23
+ })
24
+
25
+ it('maintains displayName', () => {
26
+ expect(Spinner.displayName).toBe('Spinner')
27
+ })
28
+
29
+ it('passes through HTML attributes', () => {
30
+ render(<Spinner data-testid="spinner" id="loader-1" />)
31
+ expect(screen.getByTestId('spinner')).toHaveAttribute('id', 'loader-1')
32
+ })
33
+ })
34
+
35
+ describe('Accessibility', () => {
36
+ it('has role="status"', () => {
37
+ render(<Spinner />)
38
+ expect(screen.getByRole('status')).toBeInTheDocument()
39
+ })
40
+
41
+ it('has aria-live="polite"', () => {
42
+ render(<Spinner />)
43
+ expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite')
44
+ })
45
+
46
+ it('renders default accessible label "Loading" visually hidden', () => {
47
+ render(<Spinner />)
48
+ const label = screen.getByText('Loading')
49
+ expect(label).toBeInTheDocument()
50
+ expect(label).toHaveClass('sr-only')
51
+ })
52
+
53
+ it('contains the sr-only label inside the status region', () => {
54
+ render(<Spinner />)
55
+ const status = screen.getByRole('status')
56
+ expect(status).toContainElement(screen.getByText('Loading'))
57
+ })
58
+
59
+ it('renders a custom label', () => {
60
+ render(<Spinner label="Yükleniyor" />)
61
+ const label = screen.getByText('Yükleniyor')
62
+ expect(label).toHaveClass('sr-only')
63
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument()
64
+ })
65
+
66
+ it('marks the visual indicator as aria-hidden', () => {
67
+ render(<Spinner />)
68
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
69
+ expect(ring).toHaveAttribute('aria-hidden', 'true')
70
+ })
71
+ })
72
+
73
+ describe('Animation & reduced motion', () => {
74
+ it('applies animate-spin to the default ring', () => {
75
+ render(<Spinner />)
76
+ const ring = screen.getByRole('status').querySelector('span[aria-hidden="true"]')
77
+ expect(ring).toHaveClass('animate-spin')
78
+ })
79
+
80
+ it('disables animation under prefers-reduced-motion', () => {
81
+ render(<Spinner />)
82
+ const ring = screen.getByRole('status').querySelector('span[aria-hidden="true"]')
83
+ expect(ring).toHaveClass('motion-reduce:animate-none')
84
+ })
85
+ })
86
+
87
+ describe('Size variants', () => {
88
+ it('applies md size classes by default', () => {
89
+ render(<Spinner />)
90
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
91
+ expect(ring).toHaveClass('h-6', 'w-6')
92
+ })
93
+
94
+ it('applies sm size classes', () => {
95
+ render(<Spinner size="sm" />)
96
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
97
+ expect(ring).toHaveClass('h-4', 'w-4')
98
+ })
99
+
100
+ it('applies lg size classes', () => {
101
+ render(<Spinner size="lg" />)
102
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
103
+ expect(ring).toHaveClass('h-8', 'w-8')
104
+ })
105
+
106
+ it('applies xl size classes', () => {
107
+ render(<Spinner size="xl" />)
108
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
109
+ expect(ring).toHaveClass('h-10', 'w-10')
110
+ })
111
+
112
+ it('spinnerVariants generates size classes directly', () => {
113
+ expect(spinnerVariants({ size: 'sm' })).toContain('h-4')
114
+ expect(spinnerVariants({ size: 'xl' })).toContain('h-10')
115
+ })
116
+ })
117
+
118
+ describe('Dots variant', () => {
119
+ it('renders three dots and no ring', () => {
120
+ render(<Spinner variant="dots" />)
121
+ const status = screen.getByRole('status')
122
+ expect(status.querySelector('.animate-spin')).not.toBeInTheDocument()
123
+ const dots = status.querySelectorAll('.animate-bounce')
124
+ expect(dots).toHaveLength(3)
125
+ })
126
+
127
+ it('keeps the sr-only label in dots variant', () => {
128
+ render(<Spinner variant="dots" label="Please wait" />)
129
+ const status = screen.getByRole('status')
130
+ const label = screen.getByText('Please wait')
131
+ expect(label).toHaveClass('sr-only')
132
+ expect(status).toContainElement(label)
133
+ })
134
+
135
+ it('disables dot animation under prefers-reduced-motion', () => {
136
+ render(<Spinner variant="dots" />)
137
+ const dot = screen.getByRole('status').querySelector('.animate-bounce')
138
+ expect(dot).toHaveClass('motion-reduce:animate-none')
139
+ })
140
+ })
141
+ })
@@ -424,6 +424,27 @@ 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
+
427
448
  // Also export without MoonUI prefix for backward compatibility
428
449
  export * from "./accordion";
429
450
  export * from "./alert";
@@ -473,3 +494,6 @@ export * from "./textarea";
473
494
  export * from "./toast";
474
495
  export * from "./toggle";
475
496
  export * from "./tooltip";
497
+ export * from "./kbd";
498
+ export * from "./rating";
499
+ export * from "./spinner";
@@ -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 };