@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moontra/moonui",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Premium React component library with modern design and customization",
5
5
  "author": "MoonUI",
6
6
  "license": "MIT",
@@ -101,6 +101,7 @@
101
101
  "@radix-ui/react-tabs": "^1.1.12",
102
102
  "@radix-ui/react-toast": "^1.2.14",
103
103
  "@radix-ui/react-toggle": "^1.1.10",
104
+ "@radix-ui/react-toggle-group": "^1.1.17",
104
105
  "@radix-ui/react-tooltip": "^1.2.7",
105
106
  "@tanstack/react-table": "^8.21.3",
106
107
  "class-variance-authority": "^0.7.0",
@@ -0,0 +1,183 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ButtonGroup } from '../button-group'
3
+ import { Button } from '../button'
4
+
5
+ describe('ButtonGroup Component', () => {
6
+ it('renders a role="group" container with child buttons', () => {
7
+ render(
8
+ <ButtonGroup>
9
+ <Button>One</Button>
10
+ <Button>Two</Button>
11
+ <Button>Three</Button>
12
+ </ButtonGroup>
13
+ )
14
+
15
+ const group = screen.getByRole('group')
16
+ expect(group).toBeInTheDocument()
17
+ expect(screen.getAllByRole('button')).toHaveLength(3)
18
+ expect(screen.getByRole('button', { name: /one/i })).toBeInTheDocument()
19
+ })
20
+
21
+ it('applies horizontal orientation by default', () => {
22
+ render(
23
+ <ButtonGroup>
24
+ <Button>One</Button>
25
+ </ButtonGroup>
26
+ )
27
+ const group = screen.getByRole('group')
28
+ expect(group).toHaveClass('inline-flex')
29
+ expect(group).toHaveClass('flex-row')
30
+ expect(group).not.toHaveClass('flex-col')
31
+ })
32
+
33
+ it('applies vertical orientation with flex-col', () => {
34
+ render(
35
+ <ButtonGroup orientation="vertical">
36
+ <Button>One</Button>
37
+ </ButtonGroup>
38
+ )
39
+ const group = screen.getByRole('group')
40
+ expect(group).toHaveClass('flex-col')
41
+ expect(group).not.toHaveClass('flex-row')
42
+ })
43
+
44
+ it('applies attached horizontal border-radius join classes to children (default)', () => {
45
+ render(
46
+ <ButtonGroup>
47
+ <Button>One</Button>
48
+ <Button>Two</Button>
49
+ </ButtonGroup>
50
+ )
51
+ const group = screen.getByRole('group')
52
+ expect(group).toHaveClass('[&>*:not(:first-child)]:rounded-l-none')
53
+ expect(group).toHaveClass('[&>*:not(:last-child)]:rounded-r-none')
54
+ expect(group).toHaveClass('[&>*:not(:first-child)]:-ml-px')
55
+ })
56
+
57
+ it('applies attached vertical border-radius join classes to children', () => {
58
+ render(
59
+ <ButtonGroup orientation="vertical">
60
+ <Button>One</Button>
61
+ <Button>Two</Button>
62
+ </ButtonGroup>
63
+ )
64
+ const group = screen.getByRole('group')
65
+ expect(group).toHaveClass('[&>*:not(:first-child)]:rounded-t-none')
66
+ expect(group).toHaveClass('[&>*:not(:last-child)]:rounded-b-none')
67
+ expect(group).toHaveClass('[&>*:not(:first-child)]:-mt-px')
68
+ })
69
+
70
+ it('uses spacing (gap-2) and no join classes when attached is false', () => {
71
+ render(
72
+ <ButtonGroup attached={false}>
73
+ <Button>One</Button>
74
+ <Button>Two</Button>
75
+ </ButtonGroup>
76
+ )
77
+ const group = screen.getByRole('group')
78
+ expect(group).toHaveClass('gap-2')
79
+ expect(group).not.toHaveClass('[&>*:not(:first-child)]:rounded-l-none')
80
+ expect(group).not.toHaveClass('[&>*:not(:first-child)]:-ml-px')
81
+ })
82
+
83
+ it('propagates size to child buttons via cloneElement', () => {
84
+ render(
85
+ <ButtonGroup size="sm">
86
+ <Button>One</Button>
87
+ <Button>Two</Button>
88
+ </ButtonGroup>
89
+ )
90
+ screen.getAllByRole('button').forEach((btn) => {
91
+ // sm size => h-8
92
+ expect(btn).toHaveClass('h-8')
93
+ })
94
+ })
95
+
96
+ it('propagates variant to child buttons via cloneElement', () => {
97
+ render(
98
+ <ButtonGroup variant="destructive">
99
+ <Button>One</Button>
100
+ </ButtonGroup>
101
+ )
102
+ const btn = screen.getByRole('button')
103
+ expect(btn).toHaveClass('bg-destructive')
104
+ expect(btn).not.toHaveClass('bg-primary')
105
+ })
106
+
107
+ it("lets a child's own size override the group size", () => {
108
+ render(
109
+ <ButtonGroup size="sm">
110
+ <Button size="lg">Large child</Button>
111
+ </ButtonGroup>
112
+ )
113
+ const btn = screen.getByRole('button')
114
+ // child's own lg => h-12, not group's sm h-8
115
+ expect(btn).toHaveClass('h-12')
116
+ expect(btn).not.toHaveClass('h-8')
117
+ })
118
+
119
+ it("lets a child's own variant override the group variant", () => {
120
+ render(
121
+ <ButtonGroup variant="destructive">
122
+ <Button variant="success">Success child</Button>
123
+ </ButtonGroup>
124
+ )
125
+ const btn = screen.getByRole('button')
126
+ expect(btn).toHaveClass('bg-success')
127
+ expect(btn).not.toHaveClass('bg-destructive')
128
+ })
129
+
130
+ it('does not propagate when neither size nor variant is provided', () => {
131
+ render(
132
+ <ButtonGroup>
133
+ <Button>Default</Button>
134
+ </ButtonGroup>
135
+ )
136
+ const btn = screen.getByRole('button')
137
+ // Button defaults: md => h-10, primary => bg-primary
138
+ expect(btn).toHaveClass('h-10')
139
+ expect(btn).toHaveClass('bg-primary')
140
+ })
141
+
142
+ it('gracefully passes non-Button children without injecting props', () => {
143
+ render(
144
+ <ButtonGroup size="sm">
145
+ <span data-testid="non-button">Not a button</span>
146
+ <Button>Real button</Button>
147
+ </ButtonGroup>
148
+ )
149
+ const span = screen.getByTestId('non-button')
150
+ expect(span).toBeInTheDocument()
151
+ // size prop must NOT leak onto a plain span as a DOM attribute
152
+ expect(span).not.toHaveAttribute('size')
153
+ // the real Button still receives the propagated size (h-8)
154
+ expect(screen.getByRole('button')).toHaveClass('h-8')
155
+ })
156
+
157
+ it('gracefully renders plain string children when propagating', () => {
158
+ render(<ButtonGroup size="sm">Just text</ButtonGroup>)
159
+ expect(screen.getByRole('group')).toHaveTextContent('Just text')
160
+ })
161
+
162
+ it('applies custom className', () => {
163
+ render(
164
+ <ButtonGroup className="custom-class">
165
+ <Button>One</Button>
166
+ </ButtonGroup>
167
+ )
168
+ expect(screen.getByRole('group')).toHaveClass('custom-class')
169
+ })
170
+
171
+ it('forwards ref to the container div', () => {
172
+ const ref = jest.fn()
173
+ render(
174
+ <ButtonGroup ref={ref}>
175
+ <Button>One</Button>
176
+ </ButtonGroup>
177
+ )
178
+ expect(ref).toHaveBeenCalled()
179
+ const el = ref.mock.calls[0][0]
180
+ expect(el).toBeInstanceOf(HTMLDivElement)
181
+ expect(el).toHaveAttribute('role', 'group')
182
+ })
183
+ })
@@ -0,0 +1,86 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { Kbd } from '../kbd'
5
+
6
+ describe('Kbd Component', () => {
7
+ it('renders a <kbd> element with children', () => {
8
+ render(<Kbd>K</Kbd>)
9
+ const kbd = screen.getByText('K')
10
+ expect(kbd).toBeInTheDocument()
11
+ expect(kbd.tagName).toBe('KBD')
12
+ })
13
+
14
+ it('applies base keyboard-key styles (token based)', () => {
15
+ render(<Kbd>Ctrl</Kbd>)
16
+ const kbd = screen.getByText('Ctrl')
17
+ expect(kbd).toHaveClass('inline-flex')
18
+ expect(kbd).toHaveClass('items-center')
19
+ expect(kbd).toHaveClass('border-border')
20
+ expect(kbd).toHaveClass('bg-muted')
21
+ expect(kbd).toHaveClass('text-muted-foreground')
22
+ expect(kbd).toHaveClass('font-mono')
23
+ })
24
+
25
+ it('applies custom className', () => {
26
+ render(<Kbd className="custom-kbd">Esc</Kbd>)
27
+ expect(screen.getByText('Esc')).toHaveClass('custom-kbd')
28
+ })
29
+
30
+ describe('Sizes', () => {
31
+ it('renders medium size by default', () => {
32
+ render(<Kbd>Enter</Kbd>)
33
+ const kbd = screen.getByText('Enter')
34
+ expect(kbd).toHaveClass('h-6')
35
+ expect(kbd).toHaveClass('min-w-6')
36
+ expect(kbd).toHaveClass('text-xs')
37
+ })
38
+
39
+ it('renders small size correctly', () => {
40
+ render(<Kbd size="sm">Shift</Kbd>)
41
+ const kbd = screen.getByText('Shift')
42
+ expect(kbd).toHaveClass('h-5')
43
+ expect(kbd).toHaveClass('min-w-5')
44
+ expect(kbd).toHaveClass('text-[10px]')
45
+ })
46
+
47
+ it('renders medium size explicitly', () => {
48
+ render(<Kbd size="md">Tab</Kbd>)
49
+ const kbd = screen.getByText('Tab')
50
+ expect(kbd).toHaveClass('h-6')
51
+ expect(kbd).toHaveClass('min-w-6')
52
+ expect(kbd).toHaveClass('text-xs')
53
+ })
54
+ })
55
+
56
+ describe('Children content', () => {
57
+ it('renders symbol content visibly', () => {
58
+ render(<Kbd>⌘</Kbd>)
59
+ expect(screen.getByText('⌘')).toBeInTheDocument()
60
+ })
61
+
62
+ it('renders multi-character content visibly', () => {
63
+ render(<Kbd>Ctrl</Kbd>)
64
+ expect(screen.getByText('Ctrl')).toBeInTheDocument()
65
+ })
66
+ })
67
+
68
+ describe('Ref forwarding', () => {
69
+ it('forwards ref to the kbd element', () => {
70
+ const ref = React.createRef<HTMLElement>()
71
+ render(<Kbd ref={ref}>K</Kbd>)
72
+ expect(ref.current).toBeInstanceOf(HTMLElement)
73
+ expect(ref.current?.tagName).toBe('KBD')
74
+ })
75
+ })
76
+
77
+ it('maintains displayName', () => {
78
+ expect(Kbd.displayName).toBe('Kbd')
79
+ })
80
+
81
+ it('passes through HTML attributes', () => {
82
+ render(<Kbd data-testid="kbd-test" id="kbd-1">K</Kbd>)
83
+ const kbd = screen.getByTestId('kbd-test')
84
+ expect(kbd).toHaveAttribute('id', 'kbd-1')
85
+ })
86
+ })
@@ -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
+ })