@moontra/moonui 3.0.1 → 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,291 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+
5
+ // Embla, jsdom'da boyut ölçemediği için (tüm elemanlar 0px) gerçek scroll davranışı
6
+ // üretemez — API'yi deterministik test edebilmek için embla-carousel-react mock'lanır.
7
+ jest.mock('embla-carousel-react', () => {
8
+ const mockApi = {
9
+ canScrollPrev: jest.fn(() => true),
10
+ canScrollNext: jest.fn(() => true),
11
+ scrollPrev: jest.fn(),
12
+ scrollNext: jest.fn(),
13
+ on: jest.fn(),
14
+ off: jest.fn(),
15
+ }
16
+ return {
17
+ __esModule: true,
18
+ default: jest.fn(() => [jest.fn(), mockApi]),
19
+ __mockApi: mockApi,
20
+ }
21
+ })
22
+
23
+ import {
24
+ Carousel,
25
+ CarouselContent,
26
+ CarouselItem,
27
+ CarouselPrevious,
28
+ CarouselNext,
29
+ useCarousel,
30
+ } from '../carousel'
31
+
32
+ const embla = jest.requireMock('embla-carousel-react') as {
33
+ default: jest.Mock
34
+ __mockApi: {
35
+ canScrollPrev: jest.Mock
36
+ canScrollNext: jest.Mock
37
+ scrollPrev: jest.Mock
38
+ scrollNext: jest.Mock
39
+ on: jest.Mock
40
+ off: jest.Mock
41
+ }
42
+ }
43
+ const mockApi = embla.__mockApi
44
+
45
+ const renderCarousel = (props: React.ComponentProps<typeof Carousel> = {}) =>
46
+ render(
47
+ <Carousel data-testid="carousel" {...props}>
48
+ <CarouselContent data-testid="carousel-content">
49
+ <CarouselItem data-testid="carousel-item-1">Slide 1</CarouselItem>
50
+ <CarouselItem data-testid="carousel-item-2">Slide 2</CarouselItem>
51
+ </CarouselContent>
52
+ <CarouselPrevious data-testid="carousel-previous" />
53
+ <CarouselNext data-testid="carousel-next" />
54
+ </Carousel>
55
+ )
56
+
57
+ describe('Carousel Components', () => {
58
+ beforeEach(() => {
59
+ jest.clearAllMocks()
60
+ mockApi.canScrollPrev.mockReturnValue(true)
61
+ mockApi.canScrollNext.mockReturnValue(true)
62
+ })
63
+
64
+ describe('Carousel Root', () => {
65
+ it('renders with carousel accessibility attributes', () => {
66
+ renderCarousel()
67
+
68
+ const carousel = screen.getByTestId('carousel')
69
+ expect(carousel).toBeInTheDocument()
70
+ expect(carousel).toHaveAttribute('role', 'region')
71
+ expect(carousel).toHaveAttribute('aria-roledescription', 'carousel')
72
+ })
73
+
74
+ it('applies moonui-theme and custom className', () => {
75
+ renderCarousel({ className: 'custom-carousel' })
76
+
77
+ const carousel = screen.getByTestId('carousel')
78
+ expect(carousel).toHaveClass('moonui-theme', 'relative', 'custom-carousel')
79
+ })
80
+
81
+ it('forwards ref correctly', () => {
82
+ const ref = React.createRef<HTMLDivElement>()
83
+ render(
84
+ <Carousel ref={ref}>
85
+ <CarouselContent>
86
+ <CarouselItem>Slide 1</CarouselItem>
87
+ </CarouselContent>
88
+ </Carousel>
89
+ )
90
+
91
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
92
+ })
93
+
94
+ it('maintains displayName', () => {
95
+ expect(Carousel.displayName).toBe('Carousel')
96
+ expect(CarouselContent.displayName).toBe('CarouselContent')
97
+ expect(CarouselItem.displayName).toBe('CarouselItem')
98
+ expect(CarouselPrevious.displayName).toBe('CarouselPrevious')
99
+ expect(CarouselNext.displayName).toBe('CarouselNext')
100
+ })
101
+
102
+ it('calls setApi with the embla api', () => {
103
+ const setApi = jest.fn()
104
+ renderCarousel({ setApi })
105
+
106
+ expect(setApi).toHaveBeenCalledWith(mockApi)
107
+ })
108
+
109
+ it('passes horizontal axis to embla by default', () => {
110
+ renderCarousel()
111
+
112
+ expect(embla.default).toHaveBeenCalledWith(
113
+ expect.objectContaining({ axis: 'x' }),
114
+ undefined
115
+ )
116
+ })
117
+
118
+ it('passes vertical axis to embla when orientation is vertical', () => {
119
+ renderCarousel({ orientation: 'vertical' })
120
+
121
+ expect(embla.default).toHaveBeenCalledWith(
122
+ expect.objectContaining({ axis: 'y' }),
123
+ undefined
124
+ )
125
+ })
126
+
127
+ it('forwards opts and plugins to embla', () => {
128
+ const plugins: never[] = []
129
+ renderCarousel({ opts: { loop: true }, plugins })
130
+
131
+ expect(embla.default).toHaveBeenCalledWith(
132
+ expect.objectContaining({ loop: true, axis: 'x' }),
133
+ plugins
134
+ )
135
+ })
136
+
137
+ it('subscribes to embla select and reInit events', () => {
138
+ renderCarousel()
139
+
140
+ expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function))
141
+ expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function))
142
+ })
143
+ })
144
+
145
+ describe('Keyboard Navigation', () => {
146
+ it('scrolls next on ArrowRight in horizontal orientation', () => {
147
+ renderCarousel()
148
+
149
+ fireEvent.keyDown(screen.getByTestId('carousel'), { key: 'ArrowRight' })
150
+ expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
151
+ })
152
+
153
+ it('scrolls prev on ArrowLeft in horizontal orientation', () => {
154
+ renderCarousel()
155
+
156
+ fireEvent.keyDown(screen.getByTestId('carousel'), { key: 'ArrowLeft' })
157
+ expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
158
+ })
159
+
160
+ it('scrolls with ArrowUp/ArrowDown in vertical orientation', () => {
161
+ renderCarousel({ orientation: 'vertical' })
162
+
163
+ const carousel = screen.getByTestId('carousel')
164
+ fireEvent.keyDown(carousel, { key: 'ArrowDown' })
165
+ expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
166
+
167
+ fireEvent.keyDown(carousel, { key: 'ArrowUp' })
168
+ expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
169
+ })
170
+
171
+ it('ignores horizontal arrow keys in vertical orientation', () => {
172
+ renderCarousel({ orientation: 'vertical' })
173
+
174
+ fireEvent.keyDown(screen.getByTestId('carousel'), { key: 'ArrowRight' })
175
+ expect(mockApi.scrollNext).not.toHaveBeenCalled()
176
+ })
177
+ })
178
+
179
+ describe('CarouselContent', () => {
180
+ it('applies horizontal orientation classes by default', () => {
181
+ renderCarousel()
182
+
183
+ const content = screen.getByTestId('carousel-content')
184
+ expect(content).toHaveClass('flex', '-ml-4')
185
+ // Dış sarmalayıcı overflow-hidden olmalı (embla viewport)
186
+ expect(content.parentElement).toHaveClass('overflow-hidden')
187
+ })
188
+
189
+ it('applies vertical orientation classes', () => {
190
+ renderCarousel({ orientation: 'vertical' })
191
+
192
+ const content = screen.getByTestId('carousel-content')
193
+ expect(content).toHaveClass('flex', '-mt-4', 'flex-col')
194
+ })
195
+
196
+ it('applies custom className', () => {
197
+ render(
198
+ <Carousel>
199
+ <CarouselContent className="custom-content" data-testid="content">
200
+ <CarouselItem>Slide</CarouselItem>
201
+ </CarouselContent>
202
+ </Carousel>
203
+ )
204
+
205
+ expect(screen.getByTestId('content')).toHaveClass('custom-content')
206
+ })
207
+ })
208
+
209
+ describe('CarouselItem', () => {
210
+ it('renders with slide accessibility attributes', () => {
211
+ renderCarousel()
212
+
213
+ const item = screen.getByTestId('carousel-item-1')
214
+ expect(item).toHaveAttribute('role', 'group')
215
+ expect(item).toHaveAttribute('aria-roledescription', 'slide')
216
+ })
217
+
218
+ it('applies horizontal item classes by default', () => {
219
+ renderCarousel()
220
+
221
+ const item = screen.getByTestId('carousel-item-1')
222
+ expect(item).toHaveClass('min-w-0', 'shrink-0', 'grow-0', 'basis-full', 'pl-4')
223
+ })
224
+
225
+ it('applies vertical item classes', () => {
226
+ renderCarousel({ orientation: 'vertical' })
227
+
228
+ const item = screen.getByTestId('carousel-item-1')
229
+ expect(item).toHaveClass('pt-4')
230
+ })
231
+ })
232
+
233
+ describe('CarouselPrevious / CarouselNext', () => {
234
+ it('scrolls prev when previous button is clicked', async () => {
235
+ const user = userEvent.setup()
236
+ renderCarousel()
237
+
238
+ await user.click(screen.getByTestId('carousel-previous'))
239
+ expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
240
+ })
241
+
242
+ it('scrolls next when next button is clicked', async () => {
243
+ const user = userEvent.setup()
244
+ renderCarousel()
245
+
246
+ await user.click(screen.getByTestId('carousel-next'))
247
+ expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
248
+ })
249
+
250
+ it('renders screen reader labels', () => {
251
+ renderCarousel()
252
+
253
+ expect(screen.getByText('Previous slide')).toHaveClass('sr-only')
254
+ expect(screen.getByText('Next slide')).toHaveClass('sr-only')
255
+ })
256
+
257
+ it('disables buttons when scrolling is not possible', () => {
258
+ mockApi.canScrollPrev.mockReturnValue(false)
259
+ mockApi.canScrollNext.mockReturnValue(false)
260
+ renderCarousel()
261
+
262
+ expect(screen.getByTestId('carousel-previous')).toBeDisabled()
263
+ expect(screen.getByTestId('carousel-next')).toBeDisabled()
264
+ })
265
+
266
+ it('does not scroll when disabled button is clicked', async () => {
267
+ mockApi.canScrollNext.mockReturnValue(false)
268
+ const user = userEvent.setup()
269
+ renderCarousel()
270
+
271
+ await user.click(screen.getByTestId('carousel-next'))
272
+ expect(mockApi.scrollNext).not.toHaveBeenCalled()
273
+ })
274
+ })
275
+
276
+ describe('useCarousel', () => {
277
+ it('throws when used outside of Carousel', () => {
278
+ const Broken = () => {
279
+ useCarousel()
280
+ return null
281
+ }
282
+
283
+ // React'in error boundary console çıktısını sustur
284
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
285
+ expect(() => render(<Broken />)).toThrow(
286
+ 'useCarousel must be used within a <Carousel />'
287
+ )
288
+ consoleSpy.mockRestore()
289
+ })
290
+ })
291
+ })
@@ -0,0 +1,228 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import {
4
+ InputOTP,
5
+ InputOTPGroup,
6
+ InputOTPSlot,
7
+ InputOTPSeparator,
8
+ inputOTPSlotVariants,
9
+ REGEXP_ONLY_DIGITS,
10
+ } from '../input-otp'
11
+
12
+ // Kontrollü test bileşeni — gerçek input-otp kütüphanesiyle uçtan uca çalışır
13
+ const ControlledOTP = ({
14
+ onChange,
15
+ maxLength = 6,
16
+ ...props
17
+ }: {
18
+ onChange?: (value: string) => void
19
+ maxLength?: number
20
+ } & Partial<React.ComponentProps<typeof InputOTP>>) => {
21
+ const [value, setValue] = React.useState('')
22
+ return (
23
+ <InputOTP
24
+ maxLength={maxLength}
25
+ value={value}
26
+ onChange={(newValue) => {
27
+ setValue(newValue)
28
+ onChange?.(newValue)
29
+ }}
30
+ {...props}
31
+ >
32
+ <InputOTPGroup data-testid="otp-group">
33
+ {Array.from({ length: maxLength }, (_, i) => (
34
+ <InputOTPSlot key={i} index={i} data-testid={`otp-slot-${i}`} />
35
+ ))}
36
+ </InputOTPGroup>
37
+ </InputOTP>
38
+ )
39
+ }
40
+
41
+ describe('InputOTP Components', () => {
42
+ describe('InputOTP Root', () => {
43
+ it('renders a textbox with the given maxLength', () => {
44
+ render(<ControlledOTP maxLength={6} />)
45
+
46
+ const input = screen.getByRole('textbox')
47
+ expect(input).toBeInTheDocument()
48
+ expect(input).toHaveAttribute('maxlength', '6')
49
+ })
50
+
51
+ it('applies moonui-theme to the container', () => {
52
+ const { container } = render(<ControlledOTP />)
53
+
54
+ expect(container.querySelector('.moonui-theme')).toBeInTheDocument()
55
+ })
56
+
57
+ it('merges custom containerClassName', () => {
58
+ const { container } = render(
59
+ <ControlledOTP containerClassName="custom-container" />
60
+ )
61
+
62
+ const otpContainer = container.querySelector('.custom-container')
63
+ expect(otpContainer).toBeInTheDocument()
64
+ expect(otpContainer).toHaveClass('flex', 'items-center', 'gap-2')
65
+ })
66
+
67
+ it('forwards ref to the underlying input', () => {
68
+ const ref = React.createRef<HTMLInputElement>()
69
+ render(
70
+ <InputOTP ref={ref} maxLength={4}>
71
+ <InputOTPGroup>
72
+ <InputOTPSlot index={0} />
73
+ </InputOTPGroup>
74
+ </InputOTP>
75
+ )
76
+
77
+ expect(ref.current).toBeInstanceOf(HTMLInputElement)
78
+ })
79
+
80
+ it('maintains displayName', () => {
81
+ expect(InputOTP.displayName).toBe('InputOTP')
82
+ expect(InputOTPGroup.displayName).toBe('InputOTPGroup')
83
+ expect(InputOTPSlot.displayName).toBe('InputOTPSlot')
84
+ expect(InputOTPSeparator.displayName).toBe('InputOTPSeparator')
85
+ })
86
+
87
+ it('passes through aria-label to the input', () => {
88
+ render(<ControlledOTP aria-label="One-time password" />)
89
+
90
+ expect(screen.getByRole('textbox')).toHaveAttribute(
91
+ 'aria-label',
92
+ 'One-time password'
93
+ )
94
+ })
95
+
96
+ it('disables the input when disabled prop is set', () => {
97
+ render(
98
+ <InputOTP maxLength={4} disabled>
99
+ <InputOTPGroup>
100
+ <InputOTPSlot index={0} />
101
+ </InputOTPGroup>
102
+ </InputOTP>
103
+ )
104
+
105
+ expect(screen.getByRole('textbox')).toBeDisabled()
106
+ })
107
+ })
108
+
109
+ describe('Value Entry', () => {
110
+ it('calls onChange and renders characters into slots when typing', () => {
111
+ const handleChange = jest.fn()
112
+ render(<ControlledOTP onChange={handleChange} />)
113
+
114
+ const input = screen.getByRole('textbox')
115
+ fireEvent.change(input, { target: { value: '123' } })
116
+
117
+ expect(handleChange).toHaveBeenLastCalledWith('123')
118
+ expect(screen.getByTestId('otp-slot-0')).toHaveTextContent('1')
119
+ expect(screen.getByTestId('otp-slot-1')).toHaveTextContent('2')
120
+ expect(screen.getByTestId('otp-slot-2')).toHaveTextContent('3')
121
+ expect(screen.getByTestId('otp-slot-3')).toHaveTextContent('')
122
+ })
123
+
124
+ it('supports pasting a full code at once', () => {
125
+ const handleChange = jest.fn()
126
+ render(<ControlledOTP onChange={handleChange} />)
127
+
128
+ // Paste davranışı: input değeri tek seferde tamamen değişir
129
+ const input = screen.getByRole('textbox')
130
+ fireEvent.change(input, { target: { value: '123456' } })
131
+
132
+ expect(handleChange).toHaveBeenLastCalledWith('123456')
133
+ expect(screen.getByTestId('otp-slot-5')).toHaveTextContent('6')
134
+ })
135
+
136
+ it('does not exceed maxLength', () => {
137
+ const handleChange = jest.fn()
138
+ render(<ControlledOTP maxLength={4} onChange={handleChange} />)
139
+
140
+ const input = screen.getByRole('textbox')
141
+ fireEvent.change(input, { target: { value: '12345' } })
142
+
143
+ // input-otp maxLength üstünü keser veya reddeder — 5 karakterlik değer asla yayılmaz
144
+ expect(handleChange).not.toHaveBeenCalledWith('12345')
145
+ })
146
+
147
+ it('rejects non-matching characters when pattern is digits-only', () => {
148
+ const handleChange = jest.fn()
149
+ render(<ControlledOTP pattern={REGEXP_ONLY_DIGITS} onChange={handleChange} />)
150
+
151
+ const input = screen.getByRole('textbox')
152
+ fireEvent.change(input, { target: { value: 'abc' } })
153
+
154
+ expect(handleChange).not.toHaveBeenCalled()
155
+ })
156
+ })
157
+
158
+ describe('InputOTPSlot', () => {
159
+ it('renders base styles with token-based border classes', () => {
160
+ render(<ControlledOTP />)
161
+
162
+ const slot = screen.getByTestId('otp-slot-0')
163
+ expect(slot).toHaveClass('border-y', 'border-r', 'border-input')
164
+ })
165
+
166
+ it('applies custom className', () => {
167
+ render(
168
+ <InputOTP maxLength={2}>
169
+ <InputOTPGroup>
170
+ <InputOTPSlot index={0} className="custom-slot" data-testid="slot" />
171
+ </InputOTPGroup>
172
+ </InputOTP>
173
+ )
174
+
175
+ expect(screen.getByTestId('slot')).toHaveClass('custom-slot')
176
+ })
177
+
178
+ it('marks the active slot with data-active and ring styles on focus', () => {
179
+ render(<ControlledOTP />)
180
+
181
+ const input = screen.getByRole('textbox')
182
+ fireEvent.focus(input)
183
+
184
+ const slot = screen.getByTestId('otp-slot-0')
185
+ expect(slot).toHaveAttribute('data-active')
186
+ expect(slot).toHaveClass('ring-2', 'ring-ring')
187
+ })
188
+
189
+ it('exposes active ring styling through inputOTPSlotVariants', () => {
190
+ // Aktif slot vurgusu token tabanlı ring sınıflarını içermeli
191
+ expect(inputOTPSlotVariants({ isActive: true })).toContain('ring-2')
192
+ expect(inputOTPSlotVariants({ isActive: true })).toContain('ring-ring')
193
+ expect(inputOTPSlotVariants({ isActive: false })).not.toContain('ring-2')
194
+ })
195
+ })
196
+
197
+ describe('InputOTPSeparator', () => {
198
+ it('renders with separator role', () => {
199
+ render(
200
+ <InputOTP maxLength={4}>
201
+ <InputOTPGroup>
202
+ <InputOTPSlot index={0} />
203
+ </InputOTPGroup>
204
+ <InputOTPSeparator data-testid="separator" />
205
+ <InputOTPGroup>
206
+ <InputOTPSlot index={1} />
207
+ </InputOTPGroup>
208
+ </InputOTP>
209
+ )
210
+
211
+ const separator = screen.getByTestId('separator')
212
+ expect(separator).toHaveAttribute('role', 'separator')
213
+ })
214
+
215
+ it('applies custom className', () => {
216
+ render(
217
+ <InputOTP maxLength={2}>
218
+ <InputOTPGroup>
219
+ <InputOTPSlot index={0} />
220
+ </InputOTPGroup>
221
+ <InputOTPSeparator className="custom-separator" data-testid="separator" />
222
+ </InputOTP>
223
+ )
224
+
225
+ expect(screen.getByTestId('separator')).toHaveClass('custom-separator')
226
+ })
227
+ })
228
+ })
@@ -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
+ })