@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.
- package/dist/index.d.mts +102 -1
- package/dist/index.d.ts +102 -1
- package/dist/index.global.js +19 -41
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +301 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +290 -1
- package/dist/index.mjs.map +1 -1
- package/dist/lib/theme.global.js +6 -30
- package/dist/lib/theme.global.js.map +1 -1
- package/package.json +1 -1
- 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/index.ts +24 -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
|
@@ -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 };
|