@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.
- package/dist/chunk-X5ULQZNC.global.js +33 -0
- package/dist/chunk-X5ULQZNC.global.js.map +1 -0
- package/dist/index.d.mts +169 -2
- package/dist/index.d.ts +169 -2
- package/dist/index.global.js +481 -151
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +989 -288
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +903 -253
- package/dist/index.mjs.map +1 -1
- package/dist/lib/theme.d.mts +1 -1
- package/dist/lib/theme.d.ts +1 -1
- package/dist/lib/theme.global.js +6 -30
- package/dist/lib/theme.global.js.map +1 -1
- package/dist/lib/theme.js +11 -6
- package/dist/lib/theme.js.map +1 -1
- package/dist/lib/theme.mjs +11 -6
- package/dist/lib/theme.mjs.map +1 -1
- package/package.json +3 -1
- package/src/components/ui/__tests__/carousel.test.tsx +291 -0
- package/src/components/ui/__tests__/input-otp.test.tsx +228 -0
- package/src/components/ui/__tests__/kbd.test.tsx +86 -0
- package/src/components/ui/__tests__/rating.test.tsx +218 -0
- package/src/components/ui/__tests__/spinner.test.tsx +141 -0
- package/src/components/ui/carousel.tsx +324 -0
- package/src/components/ui/index.ts +56 -0
- package/src/components/ui/input-otp.tsx +140 -0
- package/src/components/ui/kbd.tsx +66 -0
- package/src/components/ui/rating.tsx +253 -0
- package/src/components/ui/spinner.tsx +112 -0
- package/tailwind-preset.js +6 -0
- package/dist/chunk-PLK4O6WY.global.js +0 -33
- package/dist/chunk-PLK4O6WY.global.js.map +0 -1
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import useEmblaCarousel, {
|
|
5
|
+
type UseEmblaCarouselType,
|
|
6
|
+
} from "embla-carousel-react";
|
|
7
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
8
|
+
import { ArrowLeft, ArrowRight } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
import { cn } from "../../lib/utils";
|
|
11
|
+
import { Button } from "./button";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Premium Carousel Component
|
|
15
|
+
*
|
|
16
|
+
* Embla Carousel tabanlı, erişilebilir ve esnek carousel bileşeni.
|
|
17
|
+
* Yatay/dikey yön desteği, klavye navigasyonu ve plugin (autoplay vb.) desteği sunar.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type CarouselApi = UseEmblaCarouselType[1];
|
|
21
|
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
|
22
|
+
type CarouselOptions = UseCarouselParameters[0];
|
|
23
|
+
type CarouselPlugin = UseCarouselParameters[1];
|
|
24
|
+
|
|
25
|
+
export interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
26
|
+
/** Embla carousel seçenekleri (loop, align, axis vb.) */
|
|
27
|
+
opts?: CarouselOptions;
|
|
28
|
+
/** Embla plugin listesi (ör. autoplay) */
|
|
29
|
+
plugins?: CarouselPlugin;
|
|
30
|
+
/** Kaydırma yönü */
|
|
31
|
+
orientation?: "horizontal" | "vertical";
|
|
32
|
+
/** Embla API'sine dışarıdan erişmek için callback */
|
|
33
|
+
setApi?: (api: CarouselApi) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CarouselContextProps
|
|
37
|
+
extends Pick<CarouselProps, "opts" | "plugins" | "setApi"> {
|
|
38
|
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
|
39
|
+
api: ReturnType<typeof useEmblaCarousel>[1];
|
|
40
|
+
scrollPrev: () => void;
|
|
41
|
+
scrollNext: () => void;
|
|
42
|
+
canScrollPrev: boolean;
|
|
43
|
+
canScrollNext: boolean;
|
|
44
|
+
orientation: "horizontal" | "vertical";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Carousel context hook'u — Carousel alt bileşenlerinin embla API'sine
|
|
51
|
+
* ve yön bilgisine erişmesini sağlar.
|
|
52
|
+
*/
|
|
53
|
+
function useCarousel() {
|
|
54
|
+
const context = React.useContext(CarouselContext);
|
|
55
|
+
|
|
56
|
+
if (!context) {
|
|
57
|
+
throw new Error("useCarousel must be used within a <Carousel />");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return context;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* -------------------------------------------------------------------------------------------------
|
|
64
|
+
* Carousel Root
|
|
65
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
66
|
+
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
|
67
|
+
(
|
|
68
|
+
{
|
|
69
|
+
orientation = "horizontal",
|
|
70
|
+
opts,
|
|
71
|
+
setApi,
|
|
72
|
+
plugins,
|
|
73
|
+
className,
|
|
74
|
+
children,
|
|
75
|
+
...props
|
|
76
|
+
},
|
|
77
|
+
ref
|
|
78
|
+
) => {
|
|
79
|
+
// Yön bilgisi opts.axis ile de verilebilir — orientation prop'u öncelikli
|
|
80
|
+
const resolvedOrientation =
|
|
81
|
+
orientation || (opts?.axis === "y" ? "vertical" : "horizontal");
|
|
82
|
+
|
|
83
|
+
const [carouselRef, api] = useEmblaCarousel(
|
|
84
|
+
{
|
|
85
|
+
...opts,
|
|
86
|
+
axis: resolvedOrientation === "horizontal" ? "x" : "y",
|
|
87
|
+
},
|
|
88
|
+
plugins
|
|
89
|
+
);
|
|
90
|
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
|
91
|
+
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
|
92
|
+
|
|
93
|
+
// Embla "select" olayında ileri/geri butonlarının durumunu güncelle
|
|
94
|
+
const onSelect = React.useCallback((emblaApi: CarouselApi) => {
|
|
95
|
+
if (!emblaApi) return;
|
|
96
|
+
setCanScrollPrev(emblaApi.canScrollPrev());
|
|
97
|
+
setCanScrollNext(emblaApi.canScrollNext());
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const scrollPrev = React.useCallback(() => {
|
|
101
|
+
api?.scrollPrev();
|
|
102
|
+
}, [api]);
|
|
103
|
+
|
|
104
|
+
const scrollNext = React.useCallback(() => {
|
|
105
|
+
api?.scrollNext();
|
|
106
|
+
}, [api]);
|
|
107
|
+
|
|
108
|
+
// Klavye navigasyonu: yatayda sol/sağ, dikeyde yukarı/aşağı ok tuşları
|
|
109
|
+
const handleKeyDown = React.useCallback(
|
|
110
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
111
|
+
const prevKey =
|
|
112
|
+
resolvedOrientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
113
|
+
const nextKey =
|
|
114
|
+
resolvedOrientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
115
|
+
|
|
116
|
+
if (event.key === prevKey) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
scrollPrev();
|
|
119
|
+
} else if (event.key === nextKey) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
scrollNext();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
[resolvedOrientation, scrollPrev, scrollNext]
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
React.useEffect(() => {
|
|
128
|
+
if (!api || !setApi) return;
|
|
129
|
+
setApi(api);
|
|
130
|
+
}, [api, setApi]);
|
|
131
|
+
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
if (!api) return;
|
|
134
|
+
|
|
135
|
+
onSelect(api);
|
|
136
|
+
api.on("reInit", onSelect);
|
|
137
|
+
api.on("select", onSelect);
|
|
138
|
+
|
|
139
|
+
return () => {
|
|
140
|
+
api.off("reInit", onSelect);
|
|
141
|
+
api.off("select", onSelect);
|
|
142
|
+
};
|
|
143
|
+
}, [api, onSelect]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<CarouselContext.Provider
|
|
147
|
+
value={{
|
|
148
|
+
carouselRef,
|
|
149
|
+
api,
|
|
150
|
+
opts,
|
|
151
|
+
scrollPrev,
|
|
152
|
+
scrollNext,
|
|
153
|
+
canScrollPrev,
|
|
154
|
+
canScrollNext,
|
|
155
|
+
orientation: resolvedOrientation,
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<div
|
|
159
|
+
ref={ref}
|
|
160
|
+
onKeyDownCapture={handleKeyDown}
|
|
161
|
+
className={cn("moonui-theme", "relative", className)}
|
|
162
|
+
role="region"
|
|
163
|
+
aria-roledescription="carousel"
|
|
164
|
+
{...props}
|
|
165
|
+
>
|
|
166
|
+
{children}
|
|
167
|
+
</div>
|
|
168
|
+
</CarouselContext.Provider>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
Carousel.displayName = "Carousel";
|
|
173
|
+
|
|
174
|
+
/* -------------------------------------------------------------------------------------------------
|
|
175
|
+
* CarouselContent
|
|
176
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
177
|
+
const carouselContentVariants = cva("flex", {
|
|
178
|
+
variants: {
|
|
179
|
+
orientation: {
|
|
180
|
+
horizontal: "-ml-4",
|
|
181
|
+
vertical: "-mt-4 flex-col",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
defaultVariants: {
|
|
185
|
+
orientation: "horizontal",
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
export interface CarouselContentProps
|
|
190
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
191
|
+
Omit<VariantProps<typeof carouselContentVariants>, "orientation"> {}
|
|
192
|
+
|
|
193
|
+
const CarouselContent = React.forwardRef<HTMLDivElement, CarouselContentProps>(
|
|
194
|
+
({ className, ...props }, ref) => {
|
|
195
|
+
const { carouselRef, orientation } = useCarousel();
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div ref={carouselRef} className="overflow-hidden">
|
|
199
|
+
<div
|
|
200
|
+
ref={ref}
|
|
201
|
+
className={cn(carouselContentVariants({ orientation }), className)}
|
|
202
|
+
{...props}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
CarouselContent.displayName = "CarouselContent";
|
|
209
|
+
|
|
210
|
+
/* -------------------------------------------------------------------------------------------------
|
|
211
|
+
* CarouselItem
|
|
212
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
213
|
+
const carouselItemVariants = cva("min-w-0 shrink-0 grow-0 basis-full", {
|
|
214
|
+
variants: {
|
|
215
|
+
orientation: {
|
|
216
|
+
horizontal: "pl-4",
|
|
217
|
+
vertical: "pt-4",
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
defaultVariants: {
|
|
221
|
+
orientation: "horizontal",
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
export interface CarouselItemProps
|
|
226
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
227
|
+
Omit<VariantProps<typeof carouselItemVariants>, "orientation"> {}
|
|
228
|
+
|
|
229
|
+
const CarouselItem = React.forwardRef<HTMLDivElement, CarouselItemProps>(
|
|
230
|
+
({ className, ...props }, ref) => {
|
|
231
|
+
const { orientation } = useCarousel();
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div
|
|
235
|
+
ref={ref}
|
|
236
|
+
role="group"
|
|
237
|
+
aria-roledescription="slide"
|
|
238
|
+
className={cn(carouselItemVariants({ orientation }), className)}
|
|
239
|
+
{...props}
|
|
240
|
+
/>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
CarouselItem.displayName = "CarouselItem";
|
|
245
|
+
|
|
246
|
+
/* -------------------------------------------------------------------------------------------------
|
|
247
|
+
* CarouselPrevious
|
|
248
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
249
|
+
const CarouselPrevious = React.forwardRef<
|
|
250
|
+
HTMLButtonElement,
|
|
251
|
+
React.ComponentProps<typeof Button>
|
|
252
|
+
>(({ className, variant = "outline", size = "icon-sm", ...props }, ref) => {
|
|
253
|
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<Button
|
|
257
|
+
ref={ref}
|
|
258
|
+
variant={variant}
|
|
259
|
+
size={size}
|
|
260
|
+
rounded="full"
|
|
261
|
+
className={cn(
|
|
262
|
+
"absolute",
|
|
263
|
+
orientation === "horizontal"
|
|
264
|
+
? "-left-12 top-1/2 -translate-y-1/2"
|
|
265
|
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
266
|
+
className
|
|
267
|
+
)}
|
|
268
|
+
disabled={!canScrollPrev}
|
|
269
|
+
onClick={scrollPrev}
|
|
270
|
+
{...props}
|
|
271
|
+
>
|
|
272
|
+
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
|
273
|
+
<span className="sr-only">Previous slide</span>
|
|
274
|
+
</Button>
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
CarouselPrevious.displayName = "CarouselPrevious";
|
|
278
|
+
|
|
279
|
+
/* -------------------------------------------------------------------------------------------------
|
|
280
|
+
* CarouselNext
|
|
281
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
282
|
+
const CarouselNext = React.forwardRef<
|
|
283
|
+
HTMLButtonElement,
|
|
284
|
+
React.ComponentProps<typeof Button>
|
|
285
|
+
>(({ className, variant = "outline", size = "icon-sm", ...props }, ref) => {
|
|
286
|
+
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<Button
|
|
290
|
+
ref={ref}
|
|
291
|
+
variant={variant}
|
|
292
|
+
size={size}
|
|
293
|
+
rounded="full"
|
|
294
|
+
className={cn(
|
|
295
|
+
"absolute",
|
|
296
|
+
orientation === "horizontal"
|
|
297
|
+
? "-right-12 top-1/2 -translate-y-1/2"
|
|
298
|
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
299
|
+
className
|
|
300
|
+
)}
|
|
301
|
+
disabled={!canScrollNext}
|
|
302
|
+
onClick={scrollNext}
|
|
303
|
+
{...props}
|
|
304
|
+
>
|
|
305
|
+
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
|
306
|
+
<span className="sr-only">Next slide</span>
|
|
307
|
+
</Button>
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
CarouselNext.displayName = "CarouselNext";
|
|
311
|
+
|
|
312
|
+
export {
|
|
313
|
+
type CarouselApi,
|
|
314
|
+
type CarouselOptions,
|
|
315
|
+
type CarouselPlugin,
|
|
316
|
+
Carousel,
|
|
317
|
+
CarouselContent,
|
|
318
|
+
CarouselItem,
|
|
319
|
+
CarouselPrevious,
|
|
320
|
+
CarouselNext,
|
|
321
|
+
useCarousel,
|
|
322
|
+
carouselContentVariants,
|
|
323
|
+
carouselItemVariants,
|
|
324
|
+
};
|